Intégrer rencontres et assemblées pilotées par tuiles
Refond les pages Rencontres et Assemblées autour de tuiles ouvrables avec actions locales, PDF serveur, ventes, collectes, présences, décisions, postes et contributions. Ajoute les invitations publiques d'assemblée en lecture seule avec lien expirant, l'adoption formelle du rapport du trésorier avec proposeur/secondeur, et bloque les contributions tant que ce rapport n'est pas adopté. Uniformise la navigation et les entêtes, retire les modules redondants des contextes, ajoute la création de comptes membres par exécutif et les données démo reliées aux nouveaux processus. Ce commit capture un lot fonctionnel important; une refactorisation ciblée du module Assemblées, puis Trésorerie/Gouvernance, est maintenant pertinente pour isoler les règles métier et faciliter les tests.
This commit is contained in:
parent
69171c4a1d
commit
69309acd13
30 changed files with 1937 additions and 416 deletions
|
|
@ -27,6 +27,7 @@ from app.models.journal_action import JournalAction # noqa: F401
|
|||
from app.models.push_subscription import PushSubscription # noqa: F401
|
||||
from app.models.destinataire_contribution import DestinataireContribution # noqa: F401
|
||||
from app.models.rapport_adopte import RapportAdopte # noqa: F401
|
||||
from app.models.vente_jeton import VenteJeton # noqa: F401
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
|
|
@ -42,6 +43,10 @@ async def lifespan(app: FastAPI):
|
|||
await conn.execute(text("ALTER TABLE propositions ADD COLUMN IF NOT EXISTS groupe_a_decide_de TEXT"))
|
||||
await conn.execute(text("ALTER TABLE propositions DROP COLUMN IF EXISTS type_proposition"))
|
||||
await conn.execute(text("ALTER TABLE membres ADD COLUMN IF NOT EXISTS notification_preferences JSON"))
|
||||
await conn.execute(text("ALTER TABLE jetons ADD COLUMN IF NOT EXISTS reunion_id UUID REFERENCES reunions(id)"))
|
||||
await conn.execute(text("ALTER TABLE ventes_litterature ADD COLUMN IF NOT EXISTS reunion_id UUID REFERENCES reunions(id)"))
|
||||
await conn.execute(text("ALTER TABLE invitations ADD COLUMN IF NOT EXISTS cible_type VARCHAR(40)"))
|
||||
await conn.execute(text("ALTER TABLE invitations ADD COLUMN IF NOT EXISTS cible_id UUID"))
|
||||
await conn.execute(text(
|
||||
"ALTER TABLE envois_contributions "
|
||||
"ALTER COLUMN destination TYPE VARCHAR(120) USING destination::text"
|
||||
|
|
|
|||
|
|
@ -16,3 +16,5 @@ class Invitation(Base):
|
|||
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)
|
||||
cible_type: Mapped[str | None] = mapped_column(String(40))
|
||||
cible_id: Mapped[uuid.UUID | None] = mapped_column(nullable=True)
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ class Jeton(Base):
|
|||
|
||||
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"))
|
||||
membre_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("membres.id"))
|
||||
type_jeton: Mapped[str] = mapped_column(
|
||||
SAEnum(
|
||||
|
|
|
|||
25
backend/app/models/vente_jeton.py
Normal file
25
backend/app/models/vente_jeton.py
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
"""Ventes de jetons, distinctes des remises statistiques."""
|
||||
import uuid
|
||||
from datetime import date, datetime, timezone
|
||||
|
||||
from sqlalchemy import Boolean, Date, DateTime, ForeignKey, Integer, Numeric, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class VenteJeton(Base):
|
||||
__tablename__ = "ventes_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, index=True)
|
||||
reunion_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("reunions.id"))
|
||||
type_jeton: Mapped[str] = mapped_column(String(30), 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"))
|
||||
remis_tresorier: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
recu_par: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("membres.id"))
|
||||
date_remise: Mapped[date | None] = mapped_column(Date)
|
||||
cree_le: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
|
||||
|
|
@ -12,6 +12,7 @@ class VenteLitterature(Base):
|
|||
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)
|
||||
reunion_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("reunions.id"))
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -25,8 +25,8 @@ MODULES_CONSULTATION = [
|
|||
]
|
||||
|
||||
MODULES_PAR_CODE = {m.code: m for m in MODULES_CONSULTATION}
|
||||
MODULES_PAR_CODE["S0"] = ModuleTuile(code="S0", nom="Rencontre hebdomadaire", description="Saisies du contexte régulier", icone="🗓️", couleur="green")
|
||||
MODULES_PAR_CODE["M0"] = ModuleTuile(code="M0", nom="Assemblée d'affaires", description="Présences et procès-verbal", icone="🏛️", couleur="indigo")
|
||||
MODULES_PAR_CODE["S0"] = ModuleTuile(code="S0", nom="Rencontres", description="Saisies, ventes, jetons et rapports", icone="🗓️", couleur="green")
|
||||
MODULES_PAR_CODE["M0"] = ModuleTuile(code="M0", nom="Assemblées", description="Présences, procès-verbal et rapports", icone="🏛️", couleur="indigo")
|
||||
MODULES_PAR_CODE["S1"] = ModuleTuile(code="S1", nom="Collecte 7e", description="Saisir le panier", icone="💰", couleur="green")
|
||||
|
||||
MODULES_SAISIE_TOUS = [
|
||||
|
|
@ -48,9 +48,7 @@ MODULES_SAISIE = {
|
|||
"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"),
|
||||
],
|
||||
"Animateur(trice)": [],
|
||||
"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"),
|
||||
|
|
@ -83,7 +81,7 @@ async def accueil(
|
|||
if is_sysadmin(membre):
|
||||
modules_saisie_sysadmin = []
|
||||
codes_vus = set()
|
||||
for module in [MODULES_PAR_CODE["S1"], MODULES_PAR_CODE["S0"], MODULES_PAR_CODE["M0"], *MODULES_SAISIE_TOUS, *[m for mods in MODULES_SAISIE.values() for m in mods]]:
|
||||
for module in [MODULES_PAR_CODE["S0"], MODULES_PAR_CODE["M0"], *MODULES_SAISIE_TOUS, *[m for mods in MODULES_SAISIE.values() for m in mods]]:
|
||||
if module.code not in codes_vus:
|
||||
modules_saisie_sysadmin.append(module)
|
||||
codes_vus.add(module.code)
|
||||
|
|
@ -152,10 +150,8 @@ async def accueil(
|
|||
modules_saisie.append(m)
|
||||
codes_vus.add(m.code)
|
||||
if "executif" in postes_categories and "S1" not in codes_vus:
|
||||
modules_saisie.insert(0, MODULES_PAR_CODE["M0"])
|
||||
modules_saisie.insert(0, MODULES_PAR_CODE["S0"])
|
||||
modules_saisie.insert(0, ModuleTuile(code="S1", nom="Collecte 7e", description="Saisir le panier", icone="💰", couleur="green"))
|
||||
codes_vus.add("S1")
|
||||
modules_saisie.insert(1, MODULES_PAR_CODE["M0"])
|
||||
codes_vus.add("S0")
|
||||
codes_vus.add("M0")
|
||||
|
||||
|
|
|
|||
|
|
@ -249,6 +249,7 @@ async def soldes_tresorerie(
|
|||
):
|
||||
"""Soldes détaillés : relevé bancaire, sommes engagées, réserves, disponible."""
|
||||
from app.models.vente_litterature import VenteLitterature
|
||||
from app.models.vente_jeton import VenteJeton
|
||||
|
||||
gid = membre.groupe_id
|
||||
result = await db.execute(
|
||||
|
|
@ -346,6 +347,27 @@ async def soldes_tresorerie(
|
|||
"vendu_par": vendeur.prenom if vendeur else "?",
|
||||
})
|
||||
|
||||
result = await db.execute(
|
||||
select(VenteJeton).where(
|
||||
VenteJeton.groupe_id == gid,
|
||||
VenteJeton.remis_tresorier == False,
|
||||
)
|
||||
)
|
||||
ventes_jetons_non_remises = result.scalars().all()
|
||||
details_ventes_jetons = []
|
||||
total_ventes_jetons_non_remises = 0.0
|
||||
for v in ventes_jetons_non_remises:
|
||||
vendeur = await db.get(Membre, v.vendu_par) if v.vendu_par else None
|
||||
total_ventes_jetons_non_remises += float(v.montant)
|
||||
details_ventes_jetons.append({
|
||||
"id": str(v.id),
|
||||
"date": str(v.date_vente),
|
||||
"montant": float(v.montant),
|
||||
"titre": f"Jetons {v.type_jeton} (x{v.quantite})",
|
||||
"vendu_par": vendeur.prenom if vendeur else "?",
|
||||
"type": "jeton",
|
||||
})
|
||||
|
||||
return {
|
||||
"solde_banque": round(solde_banque_actuel, 2),
|
||||
"date_releve": date_releve,
|
||||
|
|
@ -359,8 +381,8 @@ async def soldes_tresorerie(
|
|||
"reserves_details": reserves_details,
|
||||
"en_possession": round(total_en_circulation, 2),
|
||||
"en_possession_details": details_circulation,
|
||||
"ventes_non_remises": round(total_ventes_non_remises, 2),
|
||||
"ventes_non_remises_details": details_ventes,
|
||||
"ventes_non_remises": round(total_ventes_non_remises + total_ventes_jetons_non_remises, 2),
|
||||
"ventes_non_remises_details": details_ventes + details_ventes_jetons,
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -376,6 +398,7 @@ async def registre_transactions(
|
|||
from app.models.vente_litterature import VenteLitterature
|
||||
from app.models.litterature import Litterature
|
||||
from app.models.transaction_comptable import TransactionComptable
|
||||
from app.models.vente_jeton import VenteJeton
|
||||
|
||||
gid = membre.groupe_id
|
||||
transactions = []
|
||||
|
|
@ -430,6 +453,24 @@ async def registre_transactions(
|
|||
"par": "Littérature",
|
||||
})
|
||||
|
||||
result = await db.execute(
|
||||
select(VenteJeton).where(
|
||||
VenteJeton.groupe_id == gid,
|
||||
VenteJeton.remis_tresorier == True,
|
||||
)
|
||||
)
|
||||
for v in result.scalars().all():
|
||||
if filtre_mois(v.date_vente):
|
||||
vendeur = await db.get(Membre, v.vendu_par) if v.vendu_par else None
|
||||
ajouter_transaction({
|
||||
"date": str(v.date_vente),
|
||||
"type": "vente_jeton",
|
||||
"description": f"Vente de jetons : {v.type_jeton} (x{v.quantite})",
|
||||
"entree": float(v.montant),
|
||||
"sortie": 0,
|
||||
"par": vendeur.prenom if vendeur else "Jetons",
|
||||
})
|
||||
|
||||
# 3. Dépenses payées
|
||||
result = await db.execute(
|
||||
select(Depense).where(Depense.groupe_id == gid, Depense.paye == True)
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ from sqlalchemy import select, func
|
|||
from app.core.database import get_db
|
||||
from app.core.security import get_current_membre, is_sysadmin
|
||||
from app.core.roles import membre_a_module
|
||||
from app.models.jeton import Jeton
|
||||
from app.models.vente_jeton import VenteJeton
|
||||
from app.models.affectation import Affectation
|
||||
from app.models.poste import Poste
|
||||
from app.schemas.phase5 import StockJetonsOut, TYPES_JETONS
|
||||
|
|
@ -37,17 +37,18 @@ 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)."""
|
||||
"""M6 — État du stock de jetons (basé sur les ventes, pas les remises)."""
|
||||
il_y_a_30_jours = date.today() - timedelta(days=30)
|
||||
|
||||
# Compter les jetons remis par type dans les 30 derniers jours
|
||||
# Compter les jetons vendus par type dans les 30 derniers jours.
|
||||
# Les remises sont statistiques et ne décrémentent pas l'inventaire.
|
||||
result = await db.execute(
|
||||
select(Jeton.type_jeton, func.count(Jeton.id))
|
||||
select(VenteJeton.type_jeton, func.coalesce(func.sum(VenteJeton.quantite), 0))
|
||||
.where(
|
||||
Jeton.groupe_id == membre.groupe_id,
|
||||
Jeton.date_remise >= il_y_a_30_jours,
|
||||
VenteJeton.groupe_id == membre.groupe_id,
|
||||
VenteJeton.date_vente >= il_y_a_30_jours,
|
||||
)
|
||||
.group_by(Jeton.type_jeton)
|
||||
.group_by(VenteJeton.type_jeton)
|
||||
)
|
||||
usage = {row[0]: row[1] for row in result.all()}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,10 +7,12 @@ from app.core.database import get_db
|
|||
from app.core.security import get_current_membre, is_sysadmin
|
||||
from app.core.roles import membre_a_module
|
||||
from app.models.jeton import Jeton
|
||||
from app.models.vente_jeton import VenteJeton
|
||||
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
|
||||
from app.models.transaction_comptable import TransactionComptable
|
||||
from app.schemas.phase5 import JetonCreate, JetonOut, TYPES_JETONS, VenteJetonCreate
|
||||
|
||||
router = APIRouter(prefix="/jetons", tags=["S2 — Remise de jeton"])
|
||||
|
||||
|
|
@ -62,6 +64,7 @@ async def remettre_jeton(
|
|||
|
||||
jeton = Jeton(
|
||||
groupe_id=membre.groupe_id,
|
||||
reunion_id=req.reunion_id,
|
||||
membre_id=req.membre_id,
|
||||
type_jeton=req.type_jeton,
|
||||
date_remise=req.date_remise,
|
||||
|
|
@ -82,6 +85,114 @@ async def remettre_jeton(
|
|||
)
|
||||
|
||||
|
||||
@router.post("/ventes")
|
||||
async def enregistrer_vente_jeton(
|
||||
req: VenteJetonCreate,
|
||||
membre=Depends(require_resp_jetons),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Enregistrer une vente de jetons. Les remises ne touchent pas l'inventaire."""
|
||||
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.quantite <= 0:
|
||||
raise HTTPException(status_code=400, detail="La quantité doit être positive")
|
||||
if req.montant < 0:
|
||||
raise HTTPException(status_code=400, detail="Le montant ne peut pas être négatif")
|
||||
vendeur_id = req.vendu_par_id or membre.id
|
||||
vendeur = await db.get(Membre, vendeur_id)
|
||||
if not vendeur or vendeur.groupe_id != membre.groupe_id or not vendeur.actif:
|
||||
raise HTTPException(status_code=404, detail="Vendeur introuvable")
|
||||
|
||||
vente = VenteJeton(
|
||||
groupe_id=membre.groupe_id,
|
||||
reunion_id=req.reunion_id,
|
||||
type_jeton=req.type_jeton,
|
||||
quantite=req.quantite,
|
||||
montant=req.montant,
|
||||
date_vente=req.date_vente,
|
||||
vendu_par=vendeur_id,
|
||||
)
|
||||
db.add(vente)
|
||||
await db.flush()
|
||||
return {
|
||||
"id": str(vente.id),
|
||||
"type_jeton": vente.type_jeton,
|
||||
"quantite": vente.quantite,
|
||||
"montant": float(vente.montant),
|
||||
"date_vente": str(vente.date_vente),
|
||||
"vendu_par_prenom": vendeur.prenom,
|
||||
"remis_tresorier": vente.remis_tresorier,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/ventes/a-recevoir")
|
||||
async def ventes_jetons_a_recevoir(
|
||||
membre=Depends(get_current_membre),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(
|
||||
select(VenteJeton)
|
||||
.where(VenteJeton.groupe_id == membre.groupe_id, VenteJeton.remis_tresorier == False)
|
||||
.order_by(desc(VenteJeton.date_vente))
|
||||
)
|
||||
out = []
|
||||
for vente in result.scalars().all():
|
||||
vendeur = await db.get(Membre, vente.vendu_par) if vente.vendu_par else None
|
||||
out.append({
|
||||
"id": str(vente.id),
|
||||
"type_jeton": vente.type_jeton,
|
||||
"quantite": vente.quantite,
|
||||
"montant": float(vente.montant),
|
||||
"date_vente": str(vente.date_vente),
|
||||
"vendu_par_prenom": vendeur.prenom if vendeur else None,
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
@router.post("/ventes/{vente_id}/recevoir")
|
||||
async def confirmer_reception_vente_jeton(
|
||||
vente_id: str,
|
||||
membre=Depends(get_current_membre),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
if not is_sysadmin(membre) and not await membre_a_module(membre.id, "S1T", db):
|
||||
result = await db.execute(
|
||||
select(Affectation).join(Poste).where(
|
||||
Affectation.membre_id == membre.id,
|
||||
Affectation.statut == "actif",
|
||||
(Poste.nom.ilike("%trésorier%") | (Poste.categorie == "executif")),
|
||||
)
|
||||
)
|
||||
if not result.scalars().first():
|
||||
raise HTTPException(status_code=403, detail="Réservé au trésorier ou à l'exécutif")
|
||||
|
||||
vente = await db.get(VenteJeton, vente_id)
|
||||
if not vente or vente.groupe_id != membre.groupe_id:
|
||||
raise HTTPException(status_code=404, detail="Vente introuvable")
|
||||
if vente.remis_tresorier:
|
||||
raise HTTPException(status_code=400, detail="Déjà reçue")
|
||||
|
||||
from datetime import date as date_type
|
||||
vente.remis_tresorier = True
|
||||
vente.recu_par = membre.id
|
||||
vente.date_remise = date_type.today()
|
||||
db.add(TransactionComptable(
|
||||
groupe_id=vente.groupe_id,
|
||||
date_transaction=vente.date_vente,
|
||||
type="vente_jeton",
|
||||
compte="encaisse",
|
||||
montant=vente.montant,
|
||||
sens="debit",
|
||||
source_table="ventes_jetons",
|
||||
source_id=vente.id,
|
||||
note="Vente de jetons reçue",
|
||||
cree_par=membre.id,
|
||||
))
|
||||
await db.flush()
|
||||
return {"message": "Réception confirmée"}
|
||||
|
||||
|
||||
@router.get("/", response_model=list[JetonOut])
|
||||
async def liste_jetons(
|
||||
limite: int = 30,
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ from sqlalchemy import select, func
|
|||
from app.core.database import get_db
|
||||
from app.core.security import get_current_membre, require_executif, hash_pin, verify_pin, is_sysadmin
|
||||
from app.models.membre import Membre
|
||||
from app.schemas.schemas import MembrePublic, MembreComplet, MembreUpdate, ChangerPinRequest
|
||||
from app.schemas.schemas import MembreCreateAdmin, MembrePublic, MembreComplet, MembreUpdate, ChangerPinRequest
|
||||
|
||||
router = APIRouter(prefix="/membres", tags=["Membres"])
|
||||
|
||||
|
|
@ -107,6 +107,48 @@ async def liste_membres(
|
|||
return [MembreComplet.model_validate(m) for m in result.scalars().all()]
|
||||
|
||||
|
||||
@router.post("/", response_model=MembreComplet)
|
||||
async def creer_membre(
|
||||
req: MembreCreateAdmin,
|
||||
executif=Depends(require_executif),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Créer un compte membre sans invitation."""
|
||||
prenom = req.prenom.strip()
|
||||
telephone = req.telephone.strip()
|
||||
courriel = req.courriel.strip().lower()
|
||||
if not prenom:
|
||||
raise HTTPException(status_code=400, detail="Le prénom est obligatoire")
|
||||
if not telephone:
|
||||
raise HTTPException(status_code=400, detail="Le téléphone est obligatoire")
|
||||
if not courriel or "@" not in courriel:
|
||||
raise HTTPException(status_code=400, detail="Courriel invalide")
|
||||
if not req.pin.isdigit() or len(req.pin) != 4:
|
||||
raise HTTPException(status_code=400, detail="Le PIN doit être composé de 4 chiffres")
|
||||
result = await db.execute(
|
||||
select(Membre).where(
|
||||
func.lower(Membre.courriel) == courriel,
|
||||
Membre.groupe_id == executif.groupe_id,
|
||||
Membre.actif == True,
|
||||
)
|
||||
)
|
||||
if result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=400, detail="Ce courriel est déjà utilisé dans ce groupe")
|
||||
membre = Membre(
|
||||
groupe_id=executif.groupe_id,
|
||||
prenom=prenom.title(),
|
||||
nom=req.nom.strip().title() if req.nom and req.nom.strip() else None,
|
||||
telephone=telephone,
|
||||
courriel=courriel,
|
||||
date_abstinence=req.date_abstinence,
|
||||
pin_hash=hash_pin(req.pin),
|
||||
actif=True,
|
||||
)
|
||||
db.add(membre)
|
||||
await db.flush()
|
||||
return MembreComplet.model_validate(membre)
|
||||
|
||||
|
||||
@router.patch("/{membre_id}/desactiver")
|
||||
async def desactiver_membre(
|
||||
membre_id: str,
|
||||
|
|
|
|||
|
|
@ -571,6 +571,7 @@ async def affectations_groupe(executif=Depends(require_executif), db: AsyncSessi
|
|||
mbr = await db.get(Membre, a.membre_id)
|
||||
out.append({
|
||||
"id": str(a.id),
|
||||
"membre_id": str(a.membre_id),
|
||||
"membre_prenom": mbr.prenom if mbr else "?",
|
||||
"poste_nom": poste.nom if poste else "?",
|
||||
"poste_categorie": poste.categorie if poste else "?",
|
||||
|
|
|
|||
|
|
@ -1,16 +1,27 @@
|
|||
"""Routes M2 — Présences en réunion d'affaires."""
|
||||
import io
|
||||
import secrets
|
||||
from html import escape
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, desc, extract
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.roles import require_secretaire
|
||||
from app.core.reunions import date_assemblee_affaires_du_mois, jour_reunion_attendu
|
||||
from app.core.security import get_current_membre
|
||||
from app.core.security import get_current_membre, require_executif
|
||||
from app.models.invitation import Invitation
|
||||
from app.models.reunion import Reunion
|
||||
from app.models.groupe import Groupe
|
||||
from app.models.presence import Presence
|
||||
from app.models.membre import Membre
|
||||
from app.models.pv_reunion import PvReunion
|
||||
from app.models.proposition import Proposition
|
||||
from app.models.poste import Poste
|
||||
from app.models.affectation import Affectation
|
||||
from app.schemas.phase4 import (
|
||||
PresencesBatch, PresenceOut, ReunionAffairesCreate, ReunionAffairesOut,
|
||||
)
|
||||
|
|
@ -18,6 +29,84 @@ from app.schemas.phase4 import (
|
|||
router = APIRouter(prefix="/presences", tags=["M2 — Présences affaires"])
|
||||
|
||||
|
||||
def _date_civile_reunion(valeur):
|
||||
if getattr(valeur, "tzinfo", None):
|
||||
return valeur.astimezone(ZoneInfo("America/Toronto")).date()
|
||||
return valeur.date()
|
||||
|
||||
|
||||
async def _postes_vacants(groupe_id, db: AsyncSession):
|
||||
result = await db.execute(select(Poste).where(Poste.actif == True).order_by(Poste.ordre_affichage))
|
||||
postes = result.scalars().all()
|
||||
vacants = []
|
||||
for poste in postes:
|
||||
affectation = (await db.execute(
|
||||
select(Affectation).where(
|
||||
Affectation.groupe_id == groupe_id,
|
||||
Affectation.poste_id == poste.id,
|
||||
Affectation.statut == "actif",
|
||||
).limit(1)
|
||||
)).scalar_one_or_none()
|
||||
if not affectation:
|
||||
vacants.append(poste)
|
||||
return vacants
|
||||
|
||||
|
||||
async def _assemblee_publique(reunion: Reunion, db: AsyncSession):
|
||||
groupe = await db.get(Groupe, reunion.groupe_id)
|
||||
jour = _date_civile_reunion(reunion.date_reunion)
|
||||
pv = (await db.execute(
|
||||
select(PvReunion).where(PvReunion.reunion_id == reunion.id).order_by(desc(PvReunion.cree_le)).limit(1)
|
||||
)).scalar_one_or_none()
|
||||
propositions = (await db.execute(
|
||||
select(Proposition).where(
|
||||
Proposition.groupe_id == reunion.groupe_id,
|
||||
Proposition.statut.in_(["proposee", "secondee", "rejetee"]),
|
||||
).order_by(Proposition.cree_le)
|
||||
)).scalars().all()
|
||||
resolutions = (await db.execute(
|
||||
select(Proposition).where(
|
||||
Proposition.groupe_id == reunion.groupe_id,
|
||||
Proposition.statut == "adoptee",
|
||||
Proposition.date_vote == jour,
|
||||
).order_by(Proposition.numero_resolution)
|
||||
)).scalars().all()
|
||||
postes = await _postes_vacants(reunion.groupe_id, db)
|
||||
return {
|
||||
"groupe": {
|
||||
"nom": groupe.nom if groupe else "Groupe",
|
||||
"adresse_local": groupe.adresse_local if groupe else None,
|
||||
},
|
||||
"assemblee": {
|
||||
"id": str(reunion.id),
|
||||
"date_reunion": reunion.date_reunion,
|
||||
"note": reunion.note,
|
||||
},
|
||||
"pv": {
|
||||
"approuve": pv.approuve,
|
||||
"contenu": pv.contenu,
|
||||
} if pv else None,
|
||||
"propositions": [
|
||||
{
|
||||
"id": str(p.id),
|
||||
"sujet": p.sujet,
|
||||
"description": p.description,
|
||||
"statut": p.statut,
|
||||
}
|
||||
for p in propositions
|
||||
],
|
||||
"decisions_du_jour": [
|
||||
{
|
||||
"numero_resolution": p.numero_resolution,
|
||||
"sujet": p.sujet,
|
||||
"decision": p.groupe_a_decide_de or p.sujet,
|
||||
}
|
||||
for p in resolutions
|
||||
],
|
||||
"postes_disponibles": [{"id": str(p.id), "nom": p.nom, "description": p.description} for p in postes],
|
||||
}
|
||||
|
||||
|
||||
@router.post("/reunion", response_model=ReunionAffairesOut)
|
||||
async def creer_reunion_affaires(
|
||||
req: ReunionAffairesCreate,
|
||||
|
|
@ -29,12 +118,13 @@ async def creer_reunion_affaires(
|
|||
|
||||
# ── Validation : réunion d'affaires prévue dans les paramètres du groupe ──
|
||||
dt = req.date_reunion
|
||||
date_civile = _date_civile_reunion(dt)
|
||||
if groupe.jour_reunion:
|
||||
jour_attendu = jour_reunion_attendu(groupe)
|
||||
if jour_attendu is not None:
|
||||
ordre = groupe.assemblee_affaires_ordre or "premiere"
|
||||
date_attendue = date_assemblee_affaires_du_mois(dt.year, dt.month, jour_attendu, ordre)
|
||||
if dt.date() != date_attendue:
|
||||
date_attendue = date_assemblee_affaires_du_mois(date_civile.year, date_civile.month, jour_attendu, ordre)
|
||||
if date_civile != date_attendue:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"La réunion d'affaires doit avoir lieu à la date prévue ({date_attendue.isoformat()})",
|
||||
|
|
@ -45,8 +135,8 @@ async def creer_reunion_affaires(
|
|||
select(Reunion).where(
|
||||
Reunion.groupe_id == membre.groupe_id,
|
||||
Reunion.type == "affaires",
|
||||
extract("year", Reunion.date_reunion) == dt.year,
|
||||
extract("month", Reunion.date_reunion) == dt.month,
|
||||
extract("year", Reunion.date_reunion) == date_civile.year,
|
||||
extract("month", Reunion.date_reunion) == date_civile.month,
|
||||
)
|
||||
)
|
||||
if result.scalars().first():
|
||||
|
|
@ -101,6 +191,194 @@ async def liste_reunions_affaires(
|
|||
return out
|
||||
|
||||
|
||||
@router.post("/reunions/{reunion_id}/invitation")
|
||||
async def creer_invitation_assemblee(
|
||||
reunion_id: str,
|
||||
membre=Depends(require_executif),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
reunion = (await db.execute(
|
||||
select(Reunion).where(
|
||||
Reunion.id == reunion_id,
|
||||
Reunion.groupe_id == membre.groupe_id,
|
||||
Reunion.type == "affaires",
|
||||
)
|
||||
)).scalar_one_or_none()
|
||||
if not reunion:
|
||||
raise HTTPException(status_code=404, detail="Assemblée introuvable")
|
||||
code = secrets.token_hex(6).upper()
|
||||
invitation = Invitation(
|
||||
groupe_id=membre.groupe_id,
|
||||
code=code,
|
||||
cree_par=membre.id,
|
||||
expire_le=datetime.now(timezone.utc) + timedelta(days=14),
|
||||
cible_type="assemblee",
|
||||
cible_id=reunion.id,
|
||||
)
|
||||
db.add(invitation)
|
||||
await db.flush()
|
||||
return {
|
||||
"code": invitation.code,
|
||||
"expire_le": invitation.expire_le,
|
||||
"path": f"/invite/assemblee/{invitation.code}",
|
||||
}
|
||||
|
||||
|
||||
@router.get("/invite/{code}")
|
||||
async def lire_assemblee_invite(
|
||||
code: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
invitation = (await db.execute(
|
||||
select(Invitation).where(
|
||||
Invitation.code == code,
|
||||
Invitation.cible_type == "assemblee",
|
||||
)
|
||||
)).scalar_one_or_none()
|
||||
if not invitation:
|
||||
raise HTTPException(status_code=404, detail="Invitation introuvable")
|
||||
if invitation.expire_le < datetime.now(timezone.utc):
|
||||
raise HTTPException(status_code=410, detail="Invitation expirée")
|
||||
reunion = await db.get(Reunion, invitation.cible_id)
|
||||
if not reunion or reunion.type != "affaires":
|
||||
raise HTTPException(status_code=404, detail="Assemblée introuvable")
|
||||
return await _assemblee_publique(reunion, db)
|
||||
|
||||
|
||||
@router.get("/reunions/{reunion_id}/rapport.pdf")
|
||||
async def rapport_assemblee_pdf(
|
||||
reunion_id: str,
|
||||
membre=Depends(get_current_membre),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Produire le rapport PDF d'une assemblée d'affaires."""
|
||||
try:
|
||||
from weasyprint import HTML
|
||||
except ImportError:
|
||||
raise HTTPException(status_code=500, detail="WeasyPrint non installé")
|
||||
|
||||
reunion = (await db.execute(
|
||||
select(Reunion).where(
|
||||
Reunion.id == reunion_id,
|
||||
Reunion.groupe_id == membre.groupe_id,
|
||||
Reunion.type == "affaires",
|
||||
)
|
||||
)).scalar_one_or_none()
|
||||
if not reunion:
|
||||
raise HTTPException(status_code=404, detail="Assemblée introuvable")
|
||||
|
||||
groupe = await db.get(Groupe, membre.groupe_id)
|
||||
jour = _date_civile_reunion(reunion.date_reunion)
|
||||
|
||||
presences = (await db.execute(
|
||||
select(Presence).where(Presence.reunion_id == reunion.id)
|
||||
)).scalars().all()
|
||||
presences_rows = []
|
||||
for presence in presences:
|
||||
mbr = await db.get(Membre, presence.membre_id)
|
||||
if presence.present:
|
||||
presences_rows.append(f"<li>{escape(mbr.prenom if mbr else '?')}</li>")
|
||||
|
||||
pv = (await db.execute(
|
||||
select(PvReunion).where(PvReunion.reunion_id == reunion.id).order_by(desc(PvReunion.cree_le)).limit(1)
|
||||
)).scalar_one_or_none()
|
||||
|
||||
propositions = (await db.execute(
|
||||
select(Proposition).where(
|
||||
Proposition.groupe_id == membre.groupe_id,
|
||||
Proposition.statut == "secondee",
|
||||
).order_by(Proposition.cree_le)
|
||||
)).scalars().all()
|
||||
propositions_rows = []
|
||||
for prop in propositions:
|
||||
proposeur = await db.get(Membre, prop.proposeur_id) if prop.proposeur_id else None
|
||||
secondeur = await db.get(Membre, prop.secondeur_id) if prop.secondeur_id else None
|
||||
propositions_rows.append(
|
||||
f"<li>{escape(prop.sujet)} ({escape(proposeur.prenom if proposeur else '—')} / {escape(secondeur.prenom if secondeur else 'à confirmer')})</li>"
|
||||
)
|
||||
|
||||
resolutions = (await db.execute(
|
||||
select(Proposition).where(
|
||||
Proposition.groupe_id == membre.groupe_id,
|
||||
Proposition.statut == "adoptee",
|
||||
Proposition.date_vote == jour,
|
||||
).order_by(Proposition.numero_resolution)
|
||||
)).scalars().all()
|
||||
resolutions_rows = [
|
||||
f"<li>{escape(r.numero_resolution or '')} {escape(r.sujet)}</li>".strip()
|
||||
for r in resolutions
|
||||
]
|
||||
|
||||
postes_rows = [f"<li>{escape(p.nom)}</li>" for p in await _postes_vacants(membre.groupe_id, db)]
|
||||
|
||||
pv_contenu = pv.contenu if pv else "\n".join([
|
||||
"Ouverture de l'assemblée.",
|
||||
"",
|
||||
"Lecture du procès-verbal précédent.",
|
||||
"",
|
||||
"Réflexions en cours:",
|
||||
*[f"- {p.sujet}" for p in propositions],
|
||||
"",
|
||||
"Décisions prises pendant cette assemblée:",
|
||||
*[f"- {r.numero_resolution or ''} {r.sujet}".strip() for r in resolutions],
|
||||
"",
|
||||
"Postes disponibles:",
|
||||
*[f"- {p.nom}" for p in await _postes_vacants(membre.groupe_id, db)],
|
||||
])
|
||||
|
||||
css = """
|
||||
body { font-family: 'DejaVu Sans', sans-serif; font-size: 10.5pt; color: #27323a; margin: 1.6cm; }
|
||||
.header { border-bottom: 3px solid #3f3c8f; padding-bottom: 12pt; margin-bottom: 18pt; }
|
||||
.groupe { color: #3f3c8f; font-size: 20pt; font-weight: 800; }
|
||||
.coords { color: #667; font-size: 9pt; margin-top: 3pt; }
|
||||
h1 { font-size: 16pt; color: #3f3c8f; margin: 0 0 12pt; }
|
||||
h2 { font-size: 12pt; color: #3f3c8f; margin: 16pt 0 6pt; border-bottom: 1px solid #dedcf4; padding-bottom: 3pt; }
|
||||
.meta { display: grid; grid-template-columns: 1fr 1fr; gap: 8pt; }
|
||||
.box { border: 1px solid #dedcf4; border-radius: 6pt; padding: 8pt; background: #fafaff; }
|
||||
.label { font-size: 8pt; color: #667; text-transform: uppercase; font-weight: 700; }
|
||||
.value { font-size: 11pt; font-weight: 700; margin-top: 2pt; }
|
||||
ul { margin-top: 4pt; }
|
||||
li { margin-bottom: 3pt; }
|
||||
.pv { white-space: pre-wrap; border: 1px solid #e5e7eb; border-radius: 6pt; padding: 8pt; background: #fcfcfd; }
|
||||
.footer { margin-top: 20pt; color: #889; font-size: 8pt; text-align: center; }
|
||||
"""
|
||||
groupe_nom = groupe.nom if groupe and groupe.nom else "Groupe"
|
||||
coords = " · ".join([
|
||||
item for item in [
|
||||
groupe.adresse_local if groupe else None,
|
||||
f"District {groupe.district}" if groupe and groupe.district else None,
|
||||
f"Région {groupe.region}" if groupe and groupe.region else None,
|
||||
] if item
|
||||
])
|
||||
html = f"""<!doctype html><html><head><meta charset="utf-8"><style>{css}</style></head><body>
|
||||
<div class="header">
|
||||
<div class="groupe">{escape(groupe_nom)}</div>
|
||||
<div class="coords">{escape(coords)}</div>
|
||||
</div>
|
||||
<h1>Rapport d'assemblée d'affaires</h1>
|
||||
<div class="meta">
|
||||
<div class="box"><div class="label">Date</div><div class="value">{jour.isoformat()}</div></div>
|
||||
<div class="box"><div class="label">Heure</div><div class="value">{reunion.date_reunion.strftime('%H:%M')}</div></div>
|
||||
<div class="box"><div class="label">Présences</div><div class="value">{len(presences_rows)}</div></div>
|
||||
<div class="box"><div class="label">PV</div><div class="value">{'Adopté' if pv and pv.approuve else 'À adopter' if pv else 'À renseigner'}</div></div>
|
||||
</div>
|
||||
<h2>Présences</h2><ul>{"".join(presences_rows) or "<li>Présences à cocher sur papier.</li>"}</ul>
|
||||
<h2>Réflexions en cours</h2><ul>{"".join(propositions_rows) or "<li>Aucune proposition secondée non résolue.</li>"}</ul>
|
||||
<h2>Décisions prises</h2><ul>{"".join(resolutions_rows) or "<li>Aucune décision enregistrée pour cette date.</li>"}</ul>
|
||||
<h2>Postes disponibles</h2><ul>{"".join(postes_rows) or "<li>Aucun poste vacant.</li>"}</ul>
|
||||
<h2>Procès-verbal</h2><div class="pv">{escape(pv_contenu)}</div>
|
||||
<div class="footer">Rapport généré automatiquement · {datetime.now().strftime('%Y-%m-%d %H:%M')}</div>
|
||||
</body></html>"""
|
||||
|
||||
pdf_bytes = HTML(string=html).write_pdf()
|
||||
filename = f"rapport_assemblee_{jour.isoformat()}.pdf"
|
||||
return StreamingResponse(
|
||||
io.BytesIO(pdf_bytes),
|
||||
media_type="application/pdf",
|
||||
headers={"Content-Disposition": f'inline; filename="{filename}"'},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/", response_model=list[PresenceOut])
|
||||
async def enregistrer_presences(
|
||||
req: PresencesBatch,
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ from sqlalchemy import select, desc, extract
|
|||
from app.core.database import get_db
|
||||
from app.core.decisions import prochain_numero_decision
|
||||
from app.core.decision_types import type_decision_depuis_proposition
|
||||
from app.core.security import get_current_membre, require_executif
|
||||
from app.core.security import get_current_membre, is_sysadmin, require_executif
|
||||
from app.core.roles import membre_a_poste_ou_executif
|
||||
from app.models.proposition import Proposition
|
||||
from app.models.reunion import Reunion
|
||||
|
|
@ -103,7 +103,7 @@ async def seconder_proposition(
|
|||
if not secondeur or secondeur.groupe_id != membre.groupe_id or not secondeur.actif:
|
||||
raise HTTPException(status_code=404, detail="Membre introuvable ou inactif")
|
||||
|
||||
if prop.proposeur_id == secondeur.id:
|
||||
if prop.proposeur_id == secondeur.id and not is_sysadmin(membre):
|
||||
raise HTTPException(status_code=400, detail="On ne peut pas seconder sa propre proposition")
|
||||
|
||||
prop.secondeur_id = secondeur.id
|
||||
|
|
|
|||
|
|
@ -30,6 +30,8 @@ MOIS_FR = [
|
|||
|
||||
class RapportTresorerieAdoption(BaseModel):
|
||||
note: str | None = None
|
||||
proposeur_id: str | None = None
|
||||
secondeur_id: str | None = None
|
||||
|
||||
|
||||
def _texte_decision_resolution(p: Proposition) -> str:
|
||||
|
|
@ -856,6 +858,17 @@ async def adopter_rapport_tresorerie(
|
|||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Cristalliser le rapport du trésorier adopté par le groupe."""
|
||||
if not req.proposeur_id or not req.secondeur_id:
|
||||
raise HTTPException(status_code=400, detail="Proposeur et secondeur requis")
|
||||
if req.proposeur_id == req.secondeur_id:
|
||||
raise HTTPException(status_code=400, detail="Le proposeur et le secondeur doivent être différents")
|
||||
proposeur = await db.get(Membre, req.proposeur_id)
|
||||
secondeur = await db.get(Membre, req.secondeur_id)
|
||||
if not proposeur or proposeur.groupe_id != membre.groupe_id or not proposeur.actif:
|
||||
raise HTTPException(status_code=404, detail="Proposeur introuvable ou inactif")
|
||||
if not secondeur or secondeur.groupe_id != membre.groupe_id or not secondeur.actif:
|
||||
raise HTTPException(status_code=404, detail="Secondeur introuvable ou inactif")
|
||||
|
||||
result = await db.execute(
|
||||
select(RapportAdopte).where(
|
||||
RapportAdopte.groupe_id == membre.groupe_id,
|
||||
|
|
@ -867,7 +880,15 @@ async def adopter_rapport_tresorerie(
|
|||
if result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=400, detail="Ce rapport est déjà adopté")
|
||||
|
||||
html_content, _, donnees = await _html_etat_resultats(annee, mois, membre, db, req.note)
|
||||
note = req.note or ""
|
||||
note_formelle = (
|
||||
f"{note.strip()}\n\n" if note.strip() else ""
|
||||
) + f"Rapport adopté sur proposition de {proposeur.prenom}, secondée par {secondeur.prenom}."
|
||||
html_content, _, donnees = await _html_etat_resultats(annee, mois, membre, db, note_formelle)
|
||||
donnees["proposeur_id"] = str(proposeur.id)
|
||||
donnees["secondeur_id"] = str(secondeur.id)
|
||||
donnees["proposeur_prenom"] = proposeur.prenom
|
||||
donnees["secondeur_prenom"] = secondeur.prenom
|
||||
rapport = RapportAdopte(
|
||||
groupe_id=membre.groupe_id,
|
||||
adopte_par_id=membre.id,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
"""Routes S4 — Rapport de réunion."""
|
||||
import io
|
||||
from html import escape
|
||||
from datetime import datetime, timezone
|
||||
from zoneinfo import ZoneInfo
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, desc, extract
|
||||
|
||||
|
|
@ -8,13 +12,24 @@ from app.core.database import get_db
|
|||
from app.core.reunions import jour_reunion_attendu
|
||||
from app.core.security import get_current_membre, require_executif
|
||||
from app.models.reunion import Reunion
|
||||
from app.models.collecte import Collecte
|
||||
from app.models.groupe import Groupe
|
||||
from app.models.membre import Membre
|
||||
from app.models.jeton import Jeton
|
||||
from app.models.litterature import Litterature
|
||||
from app.models.vente_jeton import VenteJeton
|
||||
from app.models.vente_litterature import VenteLitterature
|
||||
from app.schemas.phase1 import ReunionCreate, ReunionOut
|
||||
|
||||
router = APIRouter(prefix="/reunions", tags=["S4 — Rapport de réunion"])
|
||||
|
||||
|
||||
def _date_civile_reunion(valeur: datetime):
|
||||
if valeur.tzinfo:
|
||||
return valeur.astimezone(ZoneInfo("America/Toronto")).date()
|
||||
return valeur.date()
|
||||
|
||||
|
||||
@router.post("/", response_model=ReunionOut)
|
||||
async def creer_rapport_reunion(
|
||||
req: ReunionCreate,
|
||||
|
|
@ -26,15 +41,16 @@ async def creer_rapport_reunion(
|
|||
|
||||
# ── Validation : réunion régulière ──
|
||||
if req.type == "reguliere" and groupe.jour_reunion:
|
||||
date_civile = _date_civile_reunion(req.date_reunion)
|
||||
jour_attendu = jour_reunion_attendu(groupe)
|
||||
jour_soumis = req.date_reunion.weekday()
|
||||
jour_soumis = date_civile.weekday()
|
||||
if jour_attendu is not None and jour_soumis != jour_attendu:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"La réunion régulière doit avoir lieu le {groupe.jour_reunion}",
|
||||
)
|
||||
# Une seule régulière par semaine ISO
|
||||
iso_year, iso_week, _ = req.date_reunion.isocalendar()
|
||||
iso_year, iso_week, _ = date_civile.isocalendar()
|
||||
result = await db.execute(
|
||||
select(Reunion).where(
|
||||
Reunion.groupe_id == membre.groupe_id,
|
||||
|
|
@ -49,11 +65,17 @@ async def creer_rapport_reunion(
|
|||
detail=f"Il y a déjà une réunion régulière pour la semaine {iso_week}",
|
||||
)
|
||||
|
||||
animateur = membre
|
||||
if req.animateur_id:
|
||||
animateur = await db.get(Membre, req.animateur_id)
|
||||
if not animateur or animateur.groupe_id != membre.groupe_id or not animateur.actif:
|
||||
raise HTTPException(status_code=404, detail="Animateur introuvable ou inactif")
|
||||
|
||||
reunion = Reunion(
|
||||
groupe_id=membre.groupe_id,
|
||||
type=req.type,
|
||||
date_reunion=req.date_reunion,
|
||||
animateur_id=membre.id,
|
||||
animateur_id=animateur.id,
|
||||
theme=req.theme,
|
||||
annonces=req.annonces,
|
||||
note=req.note,
|
||||
|
|
@ -64,7 +86,7 @@ async def creer_rapport_reunion(
|
|||
id=reunion.id,
|
||||
type=reunion.type,
|
||||
date_reunion=reunion.date_reunion,
|
||||
animateur_prenom=membre.prenom,
|
||||
animateur_prenom=animateur.prenom,
|
||||
theme=reunion.theme,
|
||||
annonces=reunion.annonces,
|
||||
note=reunion.note,
|
||||
|
|
@ -102,6 +124,304 @@ async def liste_reunions(
|
|||
return out
|
||||
|
||||
|
||||
@router.get("/rencontres/details")
|
||||
async def liste_rencontres_details(
|
||||
limite: int = 80,
|
||||
membre=Depends(get_current_membre),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Lister les rencontres régulières avec leurs saisies associées."""
|
||||
result = await db.execute(
|
||||
select(Reunion)
|
||||
.where(Reunion.groupe_id == membre.groupe_id, Reunion.type == "reguliere")
|
||||
.order_by(desc(Reunion.date_reunion))
|
||||
.limit(limite)
|
||||
)
|
||||
rencontres = []
|
||||
for reunion in result.scalars().all():
|
||||
jour = reunion.date_reunion.date()
|
||||
animateur = await db.get(Membre, reunion.animateur_id) if reunion.animateur_id else None
|
||||
|
||||
collecte_result = await db.execute(
|
||||
select(Collecte).where(
|
||||
Collecte.groupe_id == membre.groupe_id,
|
||||
(Collecte.reunion_id == reunion.id) | (Collecte.date_collecte == jour),
|
||||
).limit(1)
|
||||
)
|
||||
collecte = collecte_result.scalar_one_or_none()
|
||||
collecte_out = None
|
||||
if collecte:
|
||||
detenteur = await db.get(Membre, collecte.detenteur_id) if collecte.detenteur_id else None
|
||||
collecte_out = {
|
||||
"id": str(collecte.id),
|
||||
"montant": float(collecte.montant),
|
||||
"statut": collecte.statut,
|
||||
"detenteur_prenom": detenteur.prenom if detenteur else None,
|
||||
}
|
||||
|
||||
ventes_lit_result = await db.execute(
|
||||
select(VenteLitterature).where(
|
||||
VenteLitterature.groupe_id == membre.groupe_id,
|
||||
(VenteLitterature.reunion_id == reunion.id) | (VenteLitterature.date_vente == jour),
|
||||
)
|
||||
)
|
||||
ventes_litterature = []
|
||||
for vente in ventes_lit_result.scalars().all():
|
||||
item = await db.get(Litterature, vente.litterature_id)
|
||||
vendeur = await db.get(Membre, vente.vendu_par) if vente.vendu_par else None
|
||||
ventes_litterature.append({
|
||||
"id": str(vente.id),
|
||||
"titre": item.titre if item else "?",
|
||||
"quantite": vente.quantite,
|
||||
"montant": float(vente.montant),
|
||||
"vendu_par_prenom": vendeur.prenom if vendeur else None,
|
||||
"remis_tresorier": vente.remis_tresorier,
|
||||
})
|
||||
|
||||
ventes_jetons_result = await db.execute(
|
||||
select(VenteJeton).where(
|
||||
VenteJeton.groupe_id == membre.groupe_id,
|
||||
(VenteJeton.reunion_id == reunion.id) | (VenteJeton.date_vente == jour),
|
||||
)
|
||||
)
|
||||
ventes_jetons = []
|
||||
for vente in ventes_jetons_result.scalars().all():
|
||||
vendeur = await db.get(Membre, vente.vendu_par) if vente.vendu_par else None
|
||||
ventes_jetons.append({
|
||||
"id": str(vente.id),
|
||||
"type_jeton": vente.type_jeton,
|
||||
"quantite": vente.quantite,
|
||||
"montant": float(vente.montant),
|
||||
"vendu_par_prenom": vendeur.prenom if vendeur else None,
|
||||
"remis_tresorier": vente.remis_tresorier,
|
||||
})
|
||||
|
||||
jetons_result = await db.execute(
|
||||
select(Jeton).where(
|
||||
Jeton.groupe_id == membre.groupe_id,
|
||||
(Jeton.reunion_id == reunion.id) | (Jeton.date_remise == jour),
|
||||
)
|
||||
)
|
||||
jetons = []
|
||||
for jeton in jetons_result.scalars().all():
|
||||
cible = await db.get(Membre, jeton.membre_id) if jeton.membre_id else None
|
||||
jetons.append({
|
||||
"id": str(jeton.id),
|
||||
"type_jeton": jeton.type_jeton,
|
||||
"membre_prenom": cible.prenom if cible else None,
|
||||
"annees": jeton.annees,
|
||||
})
|
||||
|
||||
rencontres.append({
|
||||
"id": str(reunion.id),
|
||||
"type": reunion.type,
|
||||
"date_reunion": reunion.date_reunion,
|
||||
"animateur_prenom": animateur.prenom if animateur else None,
|
||||
"theme": reunion.theme,
|
||||
"annonces": reunion.annonces,
|
||||
"note": reunion.note,
|
||||
"collecte": collecte_out,
|
||||
"ventes_litterature": ventes_litterature,
|
||||
"ventes_jetons": ventes_jetons,
|
||||
"jetons": jetons,
|
||||
})
|
||||
return rencontres
|
||||
|
||||
|
||||
@router.patch("/{reunion_id}", response_model=ReunionOut)
|
||||
async def modifier_reunion(
|
||||
reunion_id: str,
|
||||
req: ReunionCreate,
|
||||
membre=Depends(require_executif),
|
||||
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")
|
||||
animateur = None
|
||||
if req.animateur_id:
|
||||
animateur = await db.get(Membre, req.animateur_id)
|
||||
if not animateur or animateur.groupe_id != membre.groupe_id or not animateur.actif:
|
||||
raise HTTPException(status_code=404, detail="Animateur introuvable ou inactif")
|
||||
reunion.animateur_id = animateur.id
|
||||
reunion.theme = req.theme
|
||||
reunion.annonces = req.annonces
|
||||
reunion.note = req.note
|
||||
anim = animateur or (await db.get(Membre, reunion.animateur_id) if reunion.animateur_id else None)
|
||||
await db.flush()
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{reunion_id}/rapport.pdf")
|
||||
async def rapport_reunion_pdf(
|
||||
reunion_id: str,
|
||||
membre=Depends(get_current_membre),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
from weasyprint import HTML
|
||||
except ImportError:
|
||||
raise HTTPException(status_code=500, detail="WeasyPrint non installé")
|
||||
|
||||
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")
|
||||
|
||||
groupe = await db.get(Groupe, membre.groupe_id)
|
||||
animateur = await db.get(Membre, reunion.animateur_id) if reunion.animateur_id else None
|
||||
jour = reunion.date_reunion.date()
|
||||
|
||||
collecte = (await db.execute(
|
||||
select(Collecte).where(
|
||||
Collecte.groupe_id == membre.groupe_id,
|
||||
(Collecte.reunion_id == reunion.id) | (Collecte.date_collecte == jour),
|
||||
).limit(1)
|
||||
)).scalar_one_or_none()
|
||||
collecte_html = "<p>Aucune collecte saisie.</p>"
|
||||
if collecte:
|
||||
detenteur = await db.get(Membre, collecte.detenteur_id) if collecte.detenteur_id else None
|
||||
collecte_html = (
|
||||
"<table>"
|
||||
f"<tr><td>Montant</td><td class=\"montant\">{float(collecte.montant):.2f} $</td></tr>"
|
||||
f"<tr><td>Détenteur</td><td>{escape(detenteur.prenom if detenteur else '—')}</td></tr>"
|
||||
f"<tr><td>Statut</td><td>{escape(collecte.statut)}</td></tr>"
|
||||
"</table>"
|
||||
)
|
||||
|
||||
ventes_lit_rows = []
|
||||
ventes_lit_result = await db.execute(
|
||||
select(VenteLitterature).where(
|
||||
VenteLitterature.groupe_id == membre.groupe_id,
|
||||
(VenteLitterature.reunion_id == reunion.id) | (VenteLitterature.date_vente == jour),
|
||||
)
|
||||
)
|
||||
for vente in ventes_lit_result.scalars().all():
|
||||
item = await db.get(Litterature, vente.litterature_id)
|
||||
vendeur = await db.get(Membre, vente.vendu_par) if vente.vendu_par else None
|
||||
ventes_lit_rows.append(
|
||||
f"<tr><td>{escape(item.titre if item else '?')}</td><td>{vente.quantite}</td>"
|
||||
f"<td class=\"montant\">{float(vente.montant):.2f} $</td><td>{escape(vendeur.prenom if vendeur else '—')}</td>"
|
||||
f"<td>{'Reçue' if vente.remis_tresorier else 'À recevoir'}</td></tr>"
|
||||
)
|
||||
|
||||
ventes_jetons_rows = []
|
||||
ventes_jetons_result = await db.execute(
|
||||
select(VenteJeton).where(
|
||||
VenteJeton.groupe_id == membre.groupe_id,
|
||||
(VenteJeton.reunion_id == reunion.id) | (VenteJeton.date_vente == jour),
|
||||
)
|
||||
)
|
||||
for vente in ventes_jetons_result.scalars().all():
|
||||
vendeur = await db.get(Membre, vente.vendu_par) if vente.vendu_par else None
|
||||
ventes_jetons_rows.append(
|
||||
f"<tr><td>{escape(vente.type_jeton)}</td><td>{vente.quantite}</td>"
|
||||
f"<td class=\"montant\">{float(vente.montant):.2f} $</td><td>{escape(vendeur.prenom if vendeur else '—')}</td>"
|
||||
f"<td>{'Reçue' if vente.remis_tresorier else 'À recevoir'}</td></tr>"
|
||||
)
|
||||
|
||||
jetons_rows = []
|
||||
jetons_result = await db.execute(
|
||||
select(Jeton).where(
|
||||
Jeton.groupe_id == membre.groupe_id,
|
||||
(Jeton.reunion_id == reunion.id) | (Jeton.date_remise == jour),
|
||||
)
|
||||
)
|
||||
for jeton in jetons_result.scalars().all():
|
||||
cible = await db.get(Membre, jeton.membre_id) if jeton.membre_id else None
|
||||
detail = f"{jeton.annees} ans" if jeton.annees else ""
|
||||
jetons_rows.append(
|
||||
f"<tr><td>{escape(jeton.type_jeton)}</td><td>{escape(cible.prenom if cible else 'Anonyme')}</td><td>{escape(detail)}</td></tr>"
|
||||
)
|
||||
|
||||
annonces = reunion.annonces or []
|
||||
annonces_html = "".join(f"<li>{escape(a)}</li>" for a in annonces) or "<li>Aucune annonce.</li>"
|
||||
css = """
|
||||
body { font-family: 'DejaVu Sans', sans-serif; font-size: 10.5pt; color: #27323a; margin: 1.6cm; }
|
||||
.header { border-bottom: 3px solid #1a5276; padding-bottom: 12pt; margin-bottom: 18pt; }
|
||||
.groupe { color: #1a5276; font-size: 20pt; font-weight: 800; }
|
||||
.coords { color: #667; font-size: 9pt; margin-top: 3pt; }
|
||||
h1 { font-size: 16pt; color: #1a5276; margin: 0 0 12pt; }
|
||||
h2 { font-size: 12pt; color: #1a5276; margin: 16pt 0 6pt; border-bottom: 1px solid #d8e2ea; padding-bottom: 3pt; }
|
||||
.meta { display: grid; grid-template-columns: 1fr 1fr; gap: 8pt; }
|
||||
.box { border: 1px solid #d8e2ea; border-radius: 6pt; padding: 8pt; background: #f8fafc; }
|
||||
.label { font-size: 8pt; color: #667; text-transform: uppercase; font-weight: 700; }
|
||||
.value { font-size: 11pt; font-weight: 700; margin-top: 2pt; }
|
||||
table { width: 100%; border-collapse: collapse; margin-top: 5pt; }
|
||||
th { text-align: left; background: #1a5276; color: white; padding: 5pt; font-size: 8.5pt; }
|
||||
td { border-bottom: 1px solid #e5e9ef; padding: 5pt; vertical-align: top; }
|
||||
.montant { text-align: right; white-space: nowrap; }
|
||||
.note { white-space: pre-wrap; }
|
||||
.page-break { break-before: page; page-break-before: always; }
|
||||
.footer { margin-top: 20pt; color: #889; font-size: 8pt; text-align: center; }
|
||||
"""
|
||||
groupe_nom = groupe.nom if groupe and groupe.nom else "Groupe"
|
||||
coords = " · ".join([
|
||||
item for item in [
|
||||
groupe.adresse_local if groupe else None,
|
||||
f"District {groupe.district}" if groupe and groupe.district else None,
|
||||
f"Région {groupe.region}" if groupe and groupe.region else None,
|
||||
] if item
|
||||
])
|
||||
ventes_lit_html = (
|
||||
"<table><tr><th>Titre</th><th>Qté</th><th>Montant</th><th>Vendu par</th><th>Statut</th></tr>"
|
||||
+ "".join(ventes_lit_rows) + "</table>"
|
||||
if ventes_lit_rows else "<p>Aucune vente de littérature.</p>"
|
||||
)
|
||||
ventes_jetons_html = (
|
||||
"<table><tr><th>Type</th><th>Qté</th><th>Montant</th><th>Vendu par</th><th>Statut</th></tr>"
|
||||
+ "".join(ventes_jetons_rows) + "</table>"
|
||||
if ventes_jetons_rows else "<p>Aucune vente de jetons.</p>"
|
||||
)
|
||||
jetons_html = (
|
||||
"<table><tr><th>Type</th><th>Membre</th><th>Détail</th></tr>" + "".join(jetons_rows) + "</table>"
|
||||
if jetons_rows else "<p>Aucun jeton/gâteau comptabilisé.</p>"
|
||||
)
|
||||
html = f"""<!doctype html><html><head><meta charset="utf-8"><style>{css}</style></head><body>
|
||||
<div class="header">
|
||||
<div class="groupe">{escape(groupe_nom)}</div>
|
||||
<div class="coords">{escape(coords)}</div>
|
||||
</div>
|
||||
<h1>Rapport de rencontre</h1>
|
||||
<div class="meta">
|
||||
<div class="box"><div class="label">Date</div><div class="value">{reunion.date_reunion.date().isoformat()}</div></div>
|
||||
<div class="box"><div class="label">Heure</div><div class="value">{reunion.date_reunion.strftime('%H:%M')}</div></div>
|
||||
<div class="box"><div class="label">Sujet</div><div class="value">{escape(reunion.theme or 'Rencontre hebdomadaire')}</div></div>
|
||||
<div class="box"><div class="label">Animé par</div><div class="value">{escape(animateur.prenom if animateur else '—')}</div></div>
|
||||
</div>
|
||||
<h2>Annonces</h2><ul>{annonces_html}</ul>
|
||||
<h2>Notes</h2><div class="note">{escape(reunion.note or 'Aucune note.')}</div>
|
||||
<h2>Collecte</h2>{collecte_html}
|
||||
<div class="page-break"></div>
|
||||
<h2>Ventes de littérature</h2>{ventes_lit_html}
|
||||
<h2>Ventes de jetons</h2>{ventes_jetons_html}
|
||||
<h2>Jetons/Gâteaux</h2>{jetons_html}
|
||||
<div class="footer">Rapport généré automatiquement · {datetime.now().strftime('%Y-%m-%d %H:%M')}</div>
|
||||
</body></html>"""
|
||||
|
||||
pdf_bytes = HTML(string=html).write_pdf()
|
||||
filename = f"rapport_rencontre_{jour.isoformat()}.pdf"
|
||||
return StreamingResponse(
|
||||
io.BytesIO(pdf_bytes),
|
||||
media_type="application/pdf",
|
||||
headers={"Content-Disposition": f'inline; filename="{filename}"'},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{reunion_id}", response_model=ReunionOut)
|
||||
async def detail_reunion(
|
||||
reunion_id: str,
|
||||
|
|
|
|||
|
|
@ -63,14 +63,20 @@ async def enregistrer_vente(
|
|||
# Décrémenter le stock
|
||||
item.quantite_stock -= req.quantite
|
||||
|
||||
vendeur_id = req.vendu_par_id or membre.id
|
||||
vendeur = await db.get(Membre, vendeur_id)
|
||||
if not vendeur or vendeur.groupe_id != membre.groupe_id or not vendeur.actif:
|
||||
raise HTTPException(status_code=404, detail="Vendeur introuvable")
|
||||
|
||||
# Créer la vente
|
||||
vente = VenteLitterature(
|
||||
litterature_id=req.litterature_id,
|
||||
groupe_id=membre.groupe_id,
|
||||
reunion_id=req.reunion_id,
|
||||
quantite=req.quantite,
|
||||
montant=req.montant,
|
||||
date_vente=req.date_vente,
|
||||
vendu_par=membre.id,
|
||||
vendu_par=vendeur_id,
|
||||
)
|
||||
db.add(vente)
|
||||
await db.flush()
|
||||
|
|
@ -81,7 +87,7 @@ async def enregistrer_vente(
|
|||
quantite=vente.quantite,
|
||||
montant=float(vente.montant),
|
||||
date_vente=vente.date_vente,
|
||||
vendu_par_prenom=membre.prenom,
|
||||
vendu_par_prenom=vendeur.prenom,
|
||||
cree_le=vente.cree_le,
|
||||
)
|
||||
|
||||
|
|
@ -184,7 +190,7 @@ async def confirmer_reception_vente(
|
|||
vente.date_remise = date_type.today()
|
||||
db.add(TransactionComptable(
|
||||
groupe_id=vente.groupe_id,
|
||||
date_transaction=vente.date_remise,
|
||||
date_transaction=vente.date_vente,
|
||||
type="vente_litterature",
|
||||
compte="encaisse",
|
||||
montant=vente.montant,
|
||||
|
|
|
|||
|
|
@ -121,6 +121,7 @@ class SoldesOut(BaseModel):
|
|||
class ReunionCreate(BaseModel):
|
||||
type: str = "reguliere"
|
||||
date_reunion: datetime
|
||||
animateur_id: Optional[UUID] = None
|
||||
theme: Optional[str] = None
|
||||
annonces: Optional[list[str]] = None
|
||||
note: Optional[str] = None
|
||||
|
|
|
|||
|
|
@ -8,11 +8,21 @@ from typing import Optional
|
|||
# ── S2 Remise de jeton ──
|
||||
class JetonCreate(BaseModel):
|
||||
membre_id: Optional[UUID] = None # None pour jeton de désir anonyme
|
||||
reunion_id: Optional[UUID] = None
|
||||
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 VenteJetonCreate(BaseModel):
|
||||
reunion_id: Optional[UUID] = None
|
||||
type_jeton: str
|
||||
quantite: int
|
||||
montant: float
|
||||
date_vente: date
|
||||
vendu_par_id: Optional[UUID] = None
|
||||
|
||||
|
||||
class JetonOut(BaseModel):
|
||||
id: UUID
|
||||
membre_prenom: Optional[str] = None
|
||||
|
|
|
|||
|
|
@ -44,9 +44,11 @@ class AjustementStock(BaseModel):
|
|||
# ── S3 Vente littérature ──
|
||||
class VenteCreate(BaseModel):
|
||||
litterature_id: UUID
|
||||
reunion_id: Optional[UUID] = None
|
||||
quantite: int
|
||||
montant: float
|
||||
date_vente: date
|
||||
vendu_par_id: Optional[UUID] = None
|
||||
|
||||
|
||||
class VenteOut(BaseModel):
|
||||
|
|
|
|||
|
|
@ -58,6 +58,15 @@ class MembreUpdate(BaseModel):
|
|||
date_abstinence: Optional[date] = None
|
||||
|
||||
|
||||
class MembreCreateAdmin(BaseModel):
|
||||
prenom: str
|
||||
nom: Optional[str] = None
|
||||
telephone: str
|
||||
courriel: str
|
||||
pin: str
|
||||
date_abstinence: Optional[date] = None
|
||||
|
||||
|
||||
class ChangerPinRequest(BaseModel):
|
||||
ancien_pin: str
|
||||
nouveau_pin: str
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ from app.models.reserve import Reserve
|
|||
from app.models.reunion import Reunion
|
||||
from app.models.transaction_comptable import TransactionComptable
|
||||
from app.models.vente_litterature import VenteLitterature
|
||||
from app.models.vente_jeton import VenteJeton
|
||||
|
||||
|
||||
POSTES = [
|
||||
|
|
@ -332,12 +333,31 @@ async def _ajouter_historique_demo(db: AsyncSession, groupe: Groupe, membres: di
|
|||
item = litterature[index % len(litterature)]
|
||||
montant = float(item.prix_coutant or 2) * 2
|
||||
item.quantite_stock = max(0, item.quantite_stock - 2)
|
||||
vente = VenteLitterature(groupe_id=groupe.id, litterature_id=item.id, quantite=2, montant=montant, date_vente=jour, vendu_par=membres["karim.demo@example.test"].id, remis_tresorier=True, recu_par=tresorier.id, date_remise=jour)
|
||||
vente = VenteLitterature(groupe_id=groupe.id, reunion_id=reunion.id, litterature_id=item.id, quantite=2, montant=montant, date_vente=jour, vendu_par=membres["karim.demo@example.test"].id, remis_tresorier=True, recu_par=tresorier.id, date_remise=jour)
|
||||
db.add(vente)
|
||||
await db.flush()
|
||||
encaisse += montant
|
||||
db.add(TransactionComptable(groupe_id=groupe.id, date_transaction=jour, type="vente_litterature", compte="encaisse", montant=montant, sens="debit", source_table="ventes_litterature", source_id=vente.id, note="Vente de littérature", cree_par=tresorier.id))
|
||||
|
||||
if index % 12 == 6:
|
||||
montant = 6
|
||||
vente_jeton = VenteJeton(
|
||||
groupe_id=groupe.id,
|
||||
reunion_id=reunion.id,
|
||||
type_jeton=["desir", "1mois", "1an"][index % 3],
|
||||
quantite=2,
|
||||
montant=montant,
|
||||
date_vente=jour,
|
||||
vendu_par=membres["melanie.demo@example.test"].id,
|
||||
remis_tresorier=True,
|
||||
recu_par=tresorier.id,
|
||||
date_remise=jour,
|
||||
)
|
||||
db.add(vente_jeton)
|
||||
await db.flush()
|
||||
encaisse += montant
|
||||
db.add(TransactionComptable(groupe_id=groupe.id, date_transaction=jour, type="vente_jeton", compte="encaisse", montant=montant, sens="debit", source_table="ventes_jetons", source_id=vente_jeton.id, note="Vente de jetons", cree_par=tresorier.id))
|
||||
|
||||
if index % 13 == 4:
|
||||
montant = 125
|
||||
if banque >= montant:
|
||||
|
|
@ -359,7 +379,7 @@ async def _ajouter_historique_demo(db: AsyncSession, groupe: Groupe, membres: di
|
|||
candidature.proposition_id = decision.id
|
||||
|
||||
if index % 10 == 5:
|
||||
db.add(Jeton(groupe_id=groupe.id, membre_id=membres_actifs[index % len(membres_actifs)].id, type_jeton="multiple", date_remise=jour, annees=1 + index % 5, remis_par=membres["melanie.demo@example.test"].id))
|
||||
db.add(Jeton(groupe_id=groupe.id, reunion_id=reunion.id, membre_id=membres_actifs[index % len(membres_actifs)].id, type_jeton="multiple", date_remise=jour, annees=1 + index % 5, remis_par=membres["melanie.demo@example.test"].id))
|
||||
|
||||
if index % 17 == 7:
|
||||
reserve = reserves[["Loyer", "Littérature", "Prudentielle"][index % 3]]
|
||||
|
|
|
|||
|
|
@ -28,11 +28,13 @@ import Impliquer from './pages/Impliquer';
|
|||
import GererPostes from './pages/GererPostes';
|
||||
import ReunionHebdomadaire from './pages/ReunionHebdomadaire';
|
||||
import AssembleeAffaires from './pages/AssembleeAffaires';
|
||||
import AssembleeInvite from './pages/AssembleeInvite';
|
||||
import JournalActions from './pages/JournalActions';
|
||||
import BottomNav from './components/BottomNav';
|
||||
|
||||
export default function App() {
|
||||
const { token, page, loadAccueil, loadGroupes } = useStore();
|
||||
const { token, page, navContext, loadAccueil, loadGroupes } = useStore();
|
||||
const chemin = window.location.pathname;
|
||||
|
||||
useEffect(() => {
|
||||
const handleUnauthorized = () => useStore.getState().logout();
|
||||
|
|
@ -59,7 +61,11 @@ export default function App() {
|
|||
if (token) {
|
||||
let pageActive;
|
||||
switch (page) {
|
||||
case 'accueil': pageActive = <Accueil />; break;
|
||||
case 'accueil':
|
||||
if (navContext === 'reunion') pageActive = <ReunionHebdomadaire contexte />;
|
||||
else if (navContext === 'affaires') pageActive = <AssembleeAffaires contexte />;
|
||||
else pageActive = <Accueil />;
|
||||
break;
|
||||
case 'profil': pageActive = <Profil />; break;
|
||||
case 'S0': pageActive = <ReunionHebdomadaire />; break;
|
||||
case 'M0': pageActive = <AssembleeAffaires />; break;
|
||||
|
|
@ -98,6 +104,8 @@ export default function App() {
|
|||
);
|
||||
}
|
||||
|
||||
if (chemin.startsWith('/invite/assemblee/')) return <AssembleeInvite />;
|
||||
|
||||
// Pages publiques
|
||||
switch (page) {
|
||||
case 'inscription': return <Inscription />;
|
||||
|
|
|
|||
|
|
@ -1,21 +1,32 @@
|
|||
import React from 'react';
|
||||
import { useStore } from '../stores/store';
|
||||
|
||||
export default function PageHeader({ titre, sousTitre }) {
|
||||
const { setPage } = useStore();
|
||||
export default function PageHeader({ titre, sousTitre, retour = true, action = null }) {
|
||||
const { membre, setPage, logout } = useStore();
|
||||
return (
|
||||
<div className="sticky top-0 z-30 bg-aa-blue text-white px-3 py-2 flex items-center gap-3 shadow-sm">
|
||||
<button
|
||||
onClick={() => setPage('accueil')}
|
||||
className="min-w-[48px] min-h-[48px] rounded-xl bg-white/10 text-2xl active:bg-white/20"
|
||||
aria-label="Retour à l'accueil"
|
||||
>
|
||||
←
|
||||
</button>
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-lg font-bold leading-tight truncate">{titre}</h1>
|
||||
{sousTitre && <p className="text-blue-200 text-xs truncate">{sousTitre}</p>}
|
||||
<div className="sticky top-0 z-30 bg-aa-blue text-white px-4 pt-4 pb-5 rounded-b-2xl shadow-lg">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
{retour && (
|
||||
<button
|
||||
onClick={() => setPage('accueil')}
|
||||
className="min-h-[44px] min-w-[44px] rounded-xl bg-white/10 text-xl active:bg-white/20"
|
||||
aria-label="Retour à l'accueil"
|
||||
>
|
||||
←
|
||||
</button>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<p className="text-blue-200 text-xs">Bonjour {membre?.prenom}</p>
|
||||
<h1 className="text-xl font-bold leading-tight truncate">{titre}</h1>
|
||||
{sousTitre && <p className="text-sm text-blue-100 mt-1 truncate">{sousTitre}</p>}
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={logout} className="tap-target shrink-0 px-3 rounded-xl bg-white/10 text-blue-100 text-xs font-bold active:bg-white/20">
|
||||
Déconnexion
|
||||
</button>
|
||||
</div>
|
||||
{action && <div className="mt-3 print:hidden">{action}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,14 +12,15 @@ const COULEURS = {
|
|||
gray: 'bg-gray-50 border-gray-200 text-gray-700',
|
||||
};
|
||||
|
||||
const NAVIGABLE = ['C7', 'S0', 'S1', 'S1T', 'S2', 'S3', 'S4', 'S5', 'C1', 'C2', 'C4', 'C5', 'C9', 'G2', 'G9', 'G11', 'G12', 'G13', 'M0', 'M1', 'M2', 'M3', 'M4', 'M5', 'M6'];
|
||||
const NAVIGABLE = ['C7', 'S1', 'S1T', 'S2', 'S3', 'S4', 'S5', 'C1', 'C2', 'C4', 'C5', 'C9', 'G2', 'G9', 'G11', 'G12', 'G13', 'M0', 'M1', 'M2', 'M3', 'M4', 'M5', 'M6'];
|
||||
const MODULES_CONTEXTE = new Set(['S0', 'M0', 'M1', 'M2']);
|
||||
|
||||
const CONTEXTES = {
|
||||
accueil: {
|
||||
titre: 'Accueil',
|
||||
description: "État général et consultations courantes.",
|
||||
sections: [
|
||||
{ titre: 'À faire maintenant', codes: ['S5', 'C5', 'C9', 'S0'] },
|
||||
{ titre: 'À faire maintenant', codes: ['S5', 'C5', 'C9'] },
|
||||
{ titre: 'État', codes: ['S1T', 'C2', 'C1', 'C4'] },
|
||||
{ titre: 'Mon espace', codes: ['C7'] },
|
||||
],
|
||||
|
|
@ -27,20 +28,12 @@ const CONTEXTES = {
|
|||
reunion: {
|
||||
titre: 'Rencontres',
|
||||
description: 'Activités liées aux rencontres hebdomadaires.',
|
||||
sections: [
|
||||
{ titre: 'Rapport de réunion', codes: ['S0', 'S4'] },
|
||||
{ titre: 'Saisies', codes: ['S1', 'S2', 'S3'] },
|
||||
{ titre: 'Références', codes: ['C2', 'C4'] },
|
||||
],
|
||||
sections: [],
|
||||
},
|
||||
affaires: {
|
||||
titre: 'Assemblées',
|
||||
description: "Activités liées aux assemblées d'affaires.",
|
||||
sections: [
|
||||
{ titre: "Assemblée d'affaires", codes: ['M0', 'M2', 'M1', 'M3'] },
|
||||
{ titre: 'Gouvernance', codes: ['C5', 'C1'] },
|
||||
{ titre: 'Postes à pourvoir', codes: ['C9'] },
|
||||
],
|
||||
description: "Assemblées d'affaires.",
|
||||
sections: [],
|
||||
},
|
||||
gestion: {
|
||||
titre: 'Gestion',
|
||||
|
|
@ -48,7 +41,7 @@ const CONTEXTES = {
|
|||
sections: [
|
||||
{ titre: 'Finances', codes: ['S1T', 'S5', 'M4'] },
|
||||
{ titre: 'Membres et structure', codes: ['G2', 'G11', 'G12', 'G9', 'G13'] },
|
||||
{ titre: 'Inventaire', codes: ['C4', 'M5', 'M6'] },
|
||||
{ titre: 'Inventaire', codes: ['M5', 'M6'] },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
|
@ -81,7 +74,6 @@ function Tuile({ module, onClick }) {
|
|||
export default function Accueil() {
|
||||
const {
|
||||
membre,
|
||||
postes,
|
||||
logout,
|
||||
modulesConsultation,
|
||||
modulesSaisie,
|
||||
|
|
@ -97,7 +89,7 @@ export default function Accueil() {
|
|||
...(modulesSaisie || []),
|
||||
...(modulesConsultation || []),
|
||||
...(modulesGestion || []),
|
||||
].forEach((m) => map.set(m.code, m));
|
||||
].filter((m) => !MODULES_CONTEXTE.has(m.code)).forEach((m) => map.set(m.code, m));
|
||||
return Array.from(map.values());
|
||||
}, [modulesSaisie, modulesConsultation, modulesGestion]);
|
||||
|
||||
|
|
@ -131,18 +123,9 @@ export default function Accueil() {
|
|||
<p className="text-sm text-blue-100 mt-1">{contexte.description}</p>
|
||||
</div>
|
||||
<button onClick={logout} className="tap-target px-3 rounded-xl bg-white/10 text-blue-100 text-xs font-bold active:bg-white/20">
|
||||
Sortir
|
||||
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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-3 space-y-4">
|
||||
|
|
|
|||
|
|
@ -5,40 +5,65 @@ import PageHeader from '../components/PageHeader';
|
|||
import { dateHeureReunionIso, datesAssembleesAffaires, dateLocale, labelDateReunion } from '../utils/datesReunion';
|
||||
|
||||
const INPUT = 'w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue';
|
||||
const METHODES = [['VIREMENT', 'Virement'], ['CHEQUE', 'Chèque']];
|
||||
const STATUTS = {
|
||||
proposee: ['À seconder', 'bg-yellow-100 text-yellow-700'],
|
||||
secondee: ['Secondée', 'bg-blue-100 text-blue-700'],
|
||||
rejetee: ['Rejetée', 'bg-red-100 text-red-700'],
|
||||
};
|
||||
|
||||
export default function AssembleeAffaires() {
|
||||
export default function AssembleeAffaires({ contexte = false }) {
|
||||
const { postes, groupe } = useStore();
|
||||
const estExecutif = postes.some((p) => ['RSG', 'RSG Substitut(e)', 'Trésorier(ière)', 'Secrétaire'].includes(p));
|
||||
const choixDates = datesAssembleesAffaires(groupe);
|
||||
const estExecutif = postes.some((p) => ['Sysadmin', 'RSG', 'RSG Substitut(e)', 'Trésorier(ière)', 'Secrétaire'].includes(p));
|
||||
const choixDatesChrono = datesAssembleesAffaires(groupe);
|
||||
const choixDates = [...choixDatesChrono].reverse();
|
||||
const prochaineDate = choixDatesChrono.find((d) => d >= dateLocale()) || choixDatesChrono[choixDatesChrono.length - 1] || dateLocale();
|
||||
|
||||
const [membres, setMembres] = useState([]);
|
||||
const [reunions, setReunions] = useState([]);
|
||||
const [pvs, setPvs] = useState([]);
|
||||
const [propositions, setPropositions] = useState([]);
|
||||
const [resolutions, setResolutions] = useState([]);
|
||||
const [postesVacants, setPostesVacants] = useState([]);
|
||||
const [reunionId, setReunionId] = useState('');
|
||||
const [dateSelection, setDateSelection] = useState(dateLocale());
|
||||
const [dateReunion, setDateReunion] = useState(dateLocale());
|
||||
const [soldes, setSoldes] = useState(null);
|
||||
const [destinataires, setDestinataires] = useState([]);
|
||||
const [rapportsTresorerie, setRapportsTresorerie] = useState([]);
|
||||
const [ouverteId, setOuverteId] = useState('');
|
||||
const [action, setAction] = useState('');
|
||||
const [planifier, setPlanifier] = useState(false);
|
||||
const [dateReunion, setDateReunion] = useState(prochaineDate);
|
||||
const [note, setNote] = useState('');
|
||||
const [presencesMap, setPresencesMap] = useState({});
|
||||
const [contenu, setContenu] = useState('');
|
||||
const [pvPrecedent, setPvPrecedent] = useState(null);
|
||||
const [pvCorrection, setPvCorrection] = useState('');
|
||||
const [showPvPrecedent, setShowPvPrecedent] = useState(false);
|
||||
const [adoption, setAdoption] = useState({ proposeur_id: '', secondeur_id: '' });
|
||||
const [don, setDon] = useState({ destination: 'District 87-16', montant: '', methode: 'VIREMENT', proposeur_id: '', secondeur_id: '' });
|
||||
const [adoptionTresorier, setAdoptionTresorier] = useState({ proposeur_id: '', secondeur_id: '', note: '' });
|
||||
const [invitationTexte, setInvitationTexte] = useState('');
|
||||
const [posteOuvert, setPosteOuvert] = useState('');
|
||||
const [posteMode, setPosteMode] = useState('');
|
||||
const [motivation, setMotivation] = useState('');
|
||||
const [membreProposeId, setMembreProposeId] = useState('');
|
||||
const [nouvelleProp, setNouvelleProp] = useState({ sujet: '', description: '' });
|
||||
const [resolutionForm, setResolutionForm] = useState({});
|
||||
const [seconderPour, setSeconderPour] = useState({});
|
||||
const [msg, setMsg] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const charger = async () => {
|
||||
try {
|
||||
const [m, r, pv, props, res, postesDispo] = await Promise.all([
|
||||
const [m, r, pv, props, res, postesDispo, s, dests, rapports] = await Promise.all([
|
||||
api.get('/membres/actifs'),
|
||||
api.get('/presences/reunions'),
|
||||
api.get('/pv/'),
|
||||
api.get('/propositions/?statut=secondee&limite=100'),
|
||||
api.get('/propositions/?limite=100'),
|
||||
api.get('/propositions/resolutions'),
|
||||
api.get('/postes/disponibles'),
|
||||
api.get('/collectes/soldes'),
|
||||
api.get('/contributions/destinataires'),
|
||||
api.get('/rapports/adoptes?type_rapport=tresorerie'),
|
||||
]);
|
||||
setMembres(m);
|
||||
setReunions(r);
|
||||
|
|
@ -46,70 +71,238 @@ export default function AssembleeAffaires() {
|
|||
setPropositions(props);
|
||||
setResolutions(res);
|
||||
setPostesVacants(postesDispo);
|
||||
setSoldes(s);
|
||||
setDestinataires(dests);
|
||||
setRapportsTresorerie(rapports);
|
||||
const map = {};
|
||||
m.forEach((mb) => { map[mb.id] = true; });
|
||||
setPresencesMap(map);
|
||||
const duJour = r.find((x) => x.date_reunion?.slice(0, 10) === dateSelection);
|
||||
if (duJour) setReunionId(duJour.id);
|
||||
} catch (e) { setError(e.message); }
|
||||
const defaut = dests.find((x) => x.nom === 'District 87-16') || dests[0];
|
||||
setDon((f) => ({
|
||||
...f,
|
||||
destination: f.destination || defaut?.nom || 'District 87-16',
|
||||
montant: f.montant || (s?.disponible > 0 ? s.disponible.toFixed(2) : ''),
|
||||
proposeur_id: f.proposeur_id || m[0]?.id || '',
|
||||
secondeur_id: f.secondeur_id || m[1]?.id || '',
|
||||
}));
|
||||
setAdoptionTresorier((f) => ({
|
||||
...f,
|
||||
proposeur_id: f.proposeur_id || m[0]?.id || '',
|
||||
secondeur_id: f.secondeur_id || m[1]?.id || '',
|
||||
}));
|
||||
if (!membreProposeId && m[0]) setMembreProposeId(m[0].id);
|
||||
if (!ouverteId && r.length) setOuverteId(r[0].id);
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => { charger(); }, []);
|
||||
useEffect(() => {
|
||||
if (choixDates.length > 0) {
|
||||
if (!choixDates.includes(dateSelection)) setDateSelection(choixDates.find((d) => d >= dateLocale()) || choixDates[choixDates.length - 1]);
|
||||
if (!choixDates.includes(dateReunion)) setDateReunion(choixDates.find((d) => d >= dateLocale()) || choixDates[choixDates.length - 1]);
|
||||
}
|
||||
if (choixDates.length > 0 && !choixDates.includes(dateReunion)) setDateReunion(prochaineDate);
|
||||
}, [groupe?.jour_reunion, groupe?.assemblee_affaires_ordre]);
|
||||
|
||||
const reunionOuverte = reunions.find((r) => r.id === ouverteId);
|
||||
const dateOuverte = reunionOuverte?.date_reunion?.slice(0, 10) || prochaineDate;
|
||||
const pvJour = pvs.find((p) => p.date_reunion?.slice(0, 10) === dateOuverte);
|
||||
const resolutionsJour = resolutions.filter((r) => (r.date_vote || r.cree_le?.slice(0, 10)) === dateOuverte);
|
||||
const nbPresents = Object.values(presencesMap).filter(Boolean).length;
|
||||
const periodeTresorier = moisPrecedent(dateOuverte);
|
||||
const rapportTresorierAdopte = rapportsTresorerie.find((r) => r.annee === periodeTresorier.annee && r.mois === periodeTresorier.mois);
|
||||
|
||||
useEffect(() => {
|
||||
const duJour = reunions.find((x) => x.date_reunion?.slice(0, 10) === dateSelection);
|
||||
setReunionId(duJour?.id || '');
|
||||
const precedents = pvs.filter((p) => p.date_reunion && p.date_reunion.slice(0, 10) < dateSelection);
|
||||
if (!reunionOuverte) return;
|
||||
const precedents = pvs
|
||||
.filter((p) => p.date_reunion && p.date_reunion.slice(0, 10) < dateOuverte)
|
||||
.sort((a, b) => b.date_reunion.localeCompare(a.date_reunion));
|
||||
const precedent = precedents[0] || null;
|
||||
setPvPrecedent(precedent);
|
||||
setPvCorrection(precedent?.contenu || '');
|
||||
}, [dateSelection, reunions, pvs]);
|
||||
setContenu(pvJour?.contenu || textePvAuto(precedent, propositions, resolutionsJour, postesVacants));
|
||||
}, [ouverteId, pvs.length, propositions.length, resolutions.length, postesVacants.length]);
|
||||
|
||||
const reunion = reunions.find((r) => r.id === reunionId);
|
||||
const pvJour = pvs.find((p) => p.date_reunion?.slice(0, 10) === dateSelection);
|
||||
const resolutionsJour = resolutions.filter((r) => (r.date_vote || r.cree_le?.slice(0, 10)) === dateSelection);
|
||||
const nbPresents = Object.values(presencesMap).filter(Boolean).length;
|
||||
|
||||
const toggle = (membreId) => setPresencesMap({ ...presencesMap, [membreId]: !presencesMap[membreId] });
|
||||
const imprimer = () => window.print();
|
||||
|
||||
const creerRapport = async () => {
|
||||
const res = await api.post('/presences/reunion', {
|
||||
date_reunion: dateHeureReunionIso(dateReunion, groupe),
|
||||
note: note.trim() || null,
|
||||
});
|
||||
setReunionId(res.id);
|
||||
setReunions([res, ...reunions]);
|
||||
setDateSelection(res.date_reunion.slice(0, 10));
|
||||
return res.id;
|
||||
const ouvrir = (id) => {
|
||||
setOuverteId(ouverteId === id ? '' : id);
|
||||
setAction('');
|
||||
};
|
||||
|
||||
const enregistrer = async () => {
|
||||
const toggle = (membreId) => setPresencesMap({ ...presencesMap, [membreId]: !presencesMap[membreId] });
|
||||
const telechargerRapport = async (assemblee) => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const token = api.getToken();
|
||||
const res = await fetch(`/api/presences/reunions/${assemblee.id}/rapport.pdf`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!res.ok) throw new Error('Erreur de génération du rapport PDF');
|
||||
const blob = await res.blob();
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = `rapport_assemblee_${assemblee.date_reunion?.slice(0, 10)}.pdf`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(a.href);
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const creerRapport = async () => {
|
||||
setError('');
|
||||
setMsg('');
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await api.post('/presences/reunion', {
|
||||
date_reunion: dateHeureReunionIso(dateReunion, groupe),
|
||||
note: note.trim() || null,
|
||||
});
|
||||
setMsg('Assemblée créée');
|
||||
setPlanifier(false);
|
||||
setNote('');
|
||||
await charger();
|
||||
setOuverteId(res.id);
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const enregistrer = async () => {
|
||||
if (!reunionOuverte) return;
|
||||
setError('');
|
||||
setMsg('');
|
||||
setLoading(true);
|
||||
try {
|
||||
const id = reunionId || await creerRapport();
|
||||
const presences = Object.entries(presencesMap).map(([membre_id, present]) => ({ membre_id, present }));
|
||||
await api.post('/presences/', { reunion_id: id, presences });
|
||||
await api.post('/presences/', { reunion_id: reunionOuverte.id, presences });
|
||||
if (contenu.trim()) {
|
||||
await api.post('/pv/', {
|
||||
reunion_id: id,
|
||||
reunion_id: reunionOuverte.id,
|
||||
contenu: contenu.trim(),
|
||||
decisions: null,
|
||||
});
|
||||
}
|
||||
setMsg(contenu.trim() ? 'Présences et PV enregistrés' : 'Présences enregistrées');
|
||||
await charger();
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const enregistrerDon = async () => {
|
||||
if (!rapportTresorierAdopte) { setError("Le rapport du trésorier doit être adopté avant de décider des contributions"); return; }
|
||||
if (!don.montant || parseFloat(don.montant) <= 0) { setError('Montant invalide'); return; }
|
||||
if (!don.proposeur_id || !don.secondeur_id) { setError('Proposeur et secondeur requis'); return; }
|
||||
if (don.proposeur_id === don.secondeur_id) { setError('Le proposeur et le secondeur doivent être différents'); return; }
|
||||
setLoading(true); setError('');
|
||||
try {
|
||||
await api.post('/contributions/', {
|
||||
date_envoi: dateOuverte,
|
||||
destination: don.destination,
|
||||
montant: parseFloat(don.montant),
|
||||
methode: don.methode,
|
||||
proposeur_id: don.proposeur_id,
|
||||
secondeur_id: don.secondeur_id,
|
||||
});
|
||||
setMsg('Don engagé');
|
||||
setDon({ ...don, montant: '' });
|
||||
await charger();
|
||||
} catch (e) { setError(e.message); }
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const adopterRapportTresorier = async () => {
|
||||
if (!adoptionTresorier.proposeur_id || !adoptionTresorier.secondeur_id) { setError('Proposeur et secondeur requis'); return; }
|
||||
setLoading(true); setError('');
|
||||
try {
|
||||
await api.post(`/rapports/etat-resultats/${periodeTresorier.annee}/${periodeTresorier.mois}/adopter`, adoptionTresorier);
|
||||
setMsg('Rapport du trésorier adopté');
|
||||
await charger();
|
||||
} catch (e) { setError(e.message); }
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const genererInvitation = async (assemblee) => {
|
||||
setError('');
|
||||
try {
|
||||
const inv = await api.post(`/presences/reunions/${assemblee.id}/invitation`);
|
||||
const url = `${window.location.origin}${inv.path}`;
|
||||
setInvitationTexte([
|
||||
`Invitation à l'assemblée d'affaires du ${labelDateReunion(assemblee.date_reunion.slice(0, 10))}`,
|
||||
'',
|
||||
"Vous pouvez consulter l'ordre du jour en lecture seule ici :",
|
||||
url,
|
||||
'',
|
||||
`Lien valide jusqu'au ${new Date(inv.expire_le).toLocaleString('fr-CA')}.`,
|
||||
].join('\n'));
|
||||
setAction('Invitation');
|
||||
} catch (e) { setError(e.message); }
|
||||
};
|
||||
|
||||
const soumettreCandidature = async (posteId) => {
|
||||
setError('');
|
||||
try {
|
||||
if (posteMode === 'proposer') {
|
||||
await api.post(`/postes/candidatures/proposer?poste_id=${posteId}&membre_id=${membreProposeId}&motivation=${encodeURIComponent(motivation.trim())}`);
|
||||
setMsg('Membre proposé');
|
||||
} else {
|
||||
await api.post(`/postes/candidatures?poste_id=${posteId}&motivation=${encodeURIComponent(motivation.trim())}`);
|
||||
setMsg('Candidature soumise');
|
||||
}
|
||||
setPosteMode('');
|
||||
setMotivation('');
|
||||
await charger();
|
||||
} catch (e) { setError(e.message); }
|
||||
};
|
||||
|
||||
const soumettreProposition = async () => {
|
||||
if (!nouvelleProp.sujet.trim()) { setError("L'objet est obligatoire"); return; }
|
||||
setError('');
|
||||
try {
|
||||
await api.post('/propositions/', {
|
||||
sujet: nouvelleProp.sujet.trim(),
|
||||
description: nouvelleProp.description.trim() || null,
|
||||
});
|
||||
setNouvelleProp({ sujet: '', description: '' });
|
||||
setMsg('Proposition soumise');
|
||||
await charger();
|
||||
} catch (e) { setError(e.message); }
|
||||
};
|
||||
|
||||
const seconder = async (id, membreId = '') => {
|
||||
setError('');
|
||||
try {
|
||||
const query = membreId ? `?membre_id=${membreId}` : '';
|
||||
await api.post(`/propositions/${id}/seconder${query}`);
|
||||
setMsg('Proposition secondée');
|
||||
await charger();
|
||||
} catch (e) { setError(e.message); }
|
||||
};
|
||||
|
||||
const voter = async (id, statut) => {
|
||||
const form = resolutionForm[id] || {};
|
||||
const etantDonneQue = (form.etant_donne_que || '').trim();
|
||||
const groupeADecideDe = (form.groupe_a_decide_de || '').trim();
|
||||
if (!etantDonneQue || (statut === 'adoptee' && !groupeADecideDe)) {
|
||||
setError(statut === 'adoptee' ? 'Les deux sections de décision sont obligatoires' : 'La section "ÉTANT DONNÉ QUE" est obligatoire');
|
||||
return;
|
||||
}
|
||||
setError('');
|
||||
try {
|
||||
await api.patch(`/propositions/${id}/voter`, {
|
||||
statut,
|
||||
etant_donne_que: etantDonneQue,
|
||||
groupe_a_decide_de: groupeADecideDe || null,
|
||||
});
|
||||
setResolutionForm({ ...resolutionForm, [id]: {} });
|
||||
setMsg(statut === 'adoptee' ? 'Décision créée' : 'Proposition rejetée');
|
||||
await charger();
|
||||
} catch (e) { setError(e.message); }
|
||||
};
|
||||
|
||||
const adopterPvPrecedent = async () => {
|
||||
if (!pvPrecedent) return;
|
||||
if (!adoption.proposeur_id || !adoption.secondeur_id) {
|
||||
|
|
@ -124,11 +317,232 @@ export default function AssembleeAffaires() {
|
|||
});
|
||||
setMsg('PV précédent adopté');
|
||||
await charger();
|
||||
} catch (e) { setError(e.message); }
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
}
|
||||
};
|
||||
|
||||
const textePvAuto = [
|
||||
'Ouverture de l’assemblée.',
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100">
|
||||
<PageHeader titre="Assemblées" sousTitre="Assemblées d'affaires et procès-verbaux" retour={!contexte} />
|
||||
<div className="p-4 space-y-4">
|
||||
{msg && <div className="bg-green-50 text-green-700 p-3 rounded-xl text-center text-sm font-medium">{msg}</div>}
|
||||
{error && <div className="bg-red-50 text-red-600 p-3 rounded-xl text-center text-sm">{error}</div>}
|
||||
|
||||
{estExecutif && (
|
||||
<button onClick={() => setPlanifier(!planifier)} className="w-full p-3 bg-aa-blue text-white rounded-xl text-sm font-bold">
|
||||
Planifier une assemblée
|
||||
</button>
|
||||
)}
|
||||
|
||||
{planifier && estExecutif && (
|
||||
<Card titre="Nouvelle assemblée">
|
||||
<Champ label="Date"><SelectDate value={dateReunion} onChange={setDateReunion} dates={choixDates} /></Champ>
|
||||
<Champ label="Note"><input value={note} onChange={(e) => setNote(e.target.value)} placeholder="Note optionnelle" className={INPUT} /></Champ>
|
||||
<button onClick={creerRapport} disabled={loading} className="w-full p-3 bg-green-600 text-white rounded-xl text-sm font-bold disabled:opacity-40">Créer</button>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
{reunions.length === 0 && (
|
||||
<div className="rounded-2xl bg-white p-5 text-center text-sm text-gray-500 shadow-sm">
|
||||
<p>Aucune assemblée planifiée.</p>
|
||||
{estExecutif && <p className="mt-1 text-gray-400">Utilisez le bouton en haut de la page pour créer la première assemblée.</p>}
|
||||
</div>
|
||||
)}
|
||||
{reunions.map((r) => {
|
||||
const date = r.date_reunion?.slice(0, 10);
|
||||
const ouvert = ouverteId === r.id;
|
||||
const pv = pvs.find((p) => p.date_reunion?.slice(0, 10) === date);
|
||||
return (
|
||||
<div key={r.id} className={`bg-white rounded-2xl shadow-sm overflow-hidden ${ouvert ? 'print:block' : 'print:hidden'}`}>
|
||||
<div className="flex items-start gap-2 p-4">
|
||||
<button onClick={() => ouvrir(r.id)} className="min-w-0 flex-1 text-left active:bg-gray-50">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="font-extrabold text-gray-900">{labelDateReunion(date)} · {heure(r.date_reunion)}</p>
|
||||
<p className="text-sm text-gray-500 truncate">{pv?.approuve ? 'PV adopté' : pv ? 'PV à adopter' : 'PV à renseigner'} · {r.presences?.filter((p) => p.present).length || 0} présence(s)</p>
|
||||
</div>
|
||||
<span className="text-xl text-gray-300">{ouvert ? '⌃' : '⌄'}</span>
|
||||
</div>
|
||||
</button>
|
||||
<button onClick={() => telechargerRapport(r)} disabled={loading}
|
||||
className="shrink-0 rounded-xl bg-gray-900 px-3 py-2 text-xs font-bold text-white disabled:opacity-40 print:hidden">
|
||||
Rapport PDF
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{ouvert && (
|
||||
<div className="p-4 pt-0 space-y-3 border-t border-gray-100">
|
||||
<div className="grid grid-cols-2 gap-2 print:hidden">
|
||||
<button onClick={() => genererInvitation(r)} className={boutonAction(action === 'Invitation')}>Invitation</button>
|
||||
{pvPrecedent && <button onClick={() => setAction(action === 'PV précédent' ? '' : 'PV précédent')} className={boutonAction(action === 'PV précédent')}>PV précédent</button>}
|
||||
{estExecutif && <button onClick={() => setAction(action === 'Présences' ? '' : 'Présences')} className={boutonAction(action === 'Présences')}>Présences</button>}
|
||||
{estExecutif && <button onClick={() => setAction(action === 'Trésorier' ? '' : 'Trésorier')} className={boutonAction(action === 'Trésorier')}>Rapport trésorier</button>}
|
||||
<button onClick={() => setAction(action === 'Donner' ? '' : 'Donner')} className={boutonAction(action === 'Donner')}>Donner au suivant</button>
|
||||
<button onClick={() => setAction(action === 'Postes' ? '' : 'Postes')} className={boutonAction(action === 'Postes')}>Postes à pourvoir</button>
|
||||
<button onClick={() => setAction(action === 'Décisions' ? '' : 'Décisions')} className={boutonAction(action === 'Décisions')}>Décisions</button>
|
||||
</div>
|
||||
|
||||
{action === 'PV précédent' && pvPrecedent && (
|
||||
<Card titre="Procès-verbal précédent">
|
||||
<p className="text-xs text-gray-400">{new Date(pvPrecedent.date_reunion).toLocaleDateString('fr-CA')}</p>
|
||||
{estExecutif && !pvPrecedent.approuve ? (
|
||||
<div className="space-y-2 pt-2 border-t border-gray-100">
|
||||
<textarea value={pvCorrection} onChange={(e) => setPvCorrection(e.target.value)} rows={8} className={`${INPUT} resize-none text-sm`} />
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<MembreSelect label="Proposeur" value={adoption.proposeur_id} onChange={(v) => setAdoption({ ...adoption, proposeur_id: v })} membres={membres} />
|
||||
<MembreSelect label="Secondeur" value={adoption.secondeur_id} onChange={(v) => setAdoption({ ...adoption, secondeur_id: v })} membres={membres} />
|
||||
</div>
|
||||
<button onClick={adopterPvPrecedent} disabled={!adoption.proposeur_id || !adoption.secondeur_id} className="w-full p-3 bg-green-50 text-green-700 rounded-xl text-sm font-bold border border-green-100 disabled:opacity-40">Adopter</button>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm whitespace-pre-wrap text-gray-700">{pvPrecedent.contenu}</p>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{action === 'Invitation' && (
|
||||
<Card titre="Invitation">
|
||||
<textarea readOnly value={invitationTexte} rows={7} className={`${INPUT} resize-none text-sm`} />
|
||||
<button onClick={() => navigator.clipboard?.writeText(invitationTexte)} className="w-full p-3 bg-aa-blue text-white rounded-xl text-sm font-bold">Copier le texte</button>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{action === 'Présences' && estExecutif && (
|
||||
<Card titre={`Présences (${nbPresents}/${membres.length})`}>
|
||||
<div className="space-y-1">{membres.map((m) => (
|
||||
<button key={m.id} onClick={() => toggle(m.id)} className={`w-full flex items-center gap-3 p-3 rounded-xl transition ${presencesMap[m.id] ? 'bg-green-50 border border-green-200' : 'bg-gray-50 border border-gray-100 opacity-50'}`}>
|
||||
<span className={`w-6 h-6 rounded-lg flex items-center justify-center text-sm font-bold ${presencesMap[m.id] ? 'bg-green-500 text-white' : 'bg-gray-200 text-gray-400'}`}>{presencesMap[m.id] ? '✓' : ''}</span>
|
||||
<span className="font-medium text-gray-800">{m.prenom}</span>
|
||||
</button>
|
||||
))}</div>
|
||||
<button onClick={enregistrer} disabled={loading} className="w-full p-3 bg-green-600 text-white rounded-xl text-sm font-bold disabled:opacity-40">Enregistrer les présences</button>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{action === 'Trésorier' && estExecutif && (
|
||||
<Card titre={`Rapport du trésorier - ${periodeTresorier.libelle}`}>
|
||||
{rapportTresorierAdopte ? (
|
||||
<p className="text-sm text-green-700 bg-green-50 rounded-xl p-3">Rapport adopté. Les contributions peuvent être décidées.</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm text-gray-600">Ce rapport doit être adopté avant de décider du montant des contributions.</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Champ label="Proposeur"><MembreSelect label="Proposeur" value={adoptionTresorier.proposeur_id} onChange={(v) => setAdoptionTresorier({ ...adoptionTresorier, proposeur_id: v })} membres={membres} /></Champ>
|
||||
<Champ label="Secondeur"><MembreSelect label="Secondeur" value={adoptionTresorier.secondeur_id} onChange={(v) => setAdoptionTresorier({ ...adoptionTresorier, secondeur_id: v })} membres={membres} /></Champ>
|
||||
</div>
|
||||
<Champ label="Correction ou note"><textarea value={adoptionTresorier.note} onChange={(e) => setAdoptionTresorier({ ...adoptionTresorier, note: e.target.value })} rows={2} className={`${INPUT} resize-none text-sm`} /></Champ>
|
||||
<button onClick={adopterRapportTresorier} disabled={loading} className="w-full p-3 bg-green-600 text-white rounded-xl text-sm font-bold disabled:opacity-40">Adopter le rapport</button>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{action === 'Donner' && (
|
||||
<Card titre="Donner au suivant">
|
||||
{!rapportTresorierAdopte && <p className="text-sm text-red-700 bg-red-50 rounded-xl p-3">Adoptez d'abord le rapport du trésorier.</p>}
|
||||
{soldes && <p className="text-sm text-blue-700 bg-blue-50 rounded-xl p-3">Disponible : <span className="font-bold">{soldes.disponible.toFixed(2)} $</span></p>}
|
||||
<Champ label="Destinataire"><select value={don.destination} onChange={(e) => setDon({ ...don, destination: e.target.value })} className={INPUT}>{destinataires.map((d) => <option key={d.id} value={d.nom}>{d.nom}</option>)}</select></Champ>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Champ label="Montant"><input type="number" inputMode="decimal" step="0.01" value={don.montant} onChange={(e) => setDon({ ...don, montant: e.target.value })} className={INPUT} /></Champ>
|
||||
<Champ label="Méthode"><select value={don.methode} onChange={(e) => setDon({ ...don, methode: e.target.value })} className={INPUT}>{METHODES.map(([v, l]) => <option key={v} value={v}>{l}</option>)}</select></Champ>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Champ label="Proposeur"><MembreSelect label="Proposeur" value={don.proposeur_id} onChange={(v) => setDon({ ...don, proposeur_id: v })} membres={membres} /></Champ>
|
||||
<Champ label="Secondeur"><MembreSelect label="Secondeur" value={don.secondeur_id} onChange={(v) => setDon({ ...don, secondeur_id: v })} membres={membres} /></Champ>
|
||||
</div>
|
||||
<button onClick={enregistrerDon} disabled={loading || !rapportTresorierAdopte} className="w-full p-3 bg-green-600 text-white rounded-xl text-sm font-bold disabled:opacity-40">Créer l'envoi</button>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{action === 'Postes' && (
|
||||
<Card titre="Postes à pourvoir">
|
||||
{postesVacants.length === 0 ? <p className="text-sm text-gray-500">Aucun poste vacant.</p> : postesVacants.map((p) => {
|
||||
const ouvertPoste = posteOuvert === p.id;
|
||||
return (
|
||||
<div key={p.id} className="rounded-xl bg-gray-50 overflow-hidden">
|
||||
<button onClick={() => { setPosteOuvert(ouvertPoste ? '' : p.id); setPosteMode(''); }} className="w-full p-3 text-left">
|
||||
<p className="font-bold text-gray-800">{p.nom}</p>
|
||||
{p.description && <p className="text-sm text-gray-500 line-clamp-2">{p.description}</p>}
|
||||
</button>
|
||||
{ouvertPoste && (
|
||||
<div className="p-3 pt-0 space-y-2">
|
||||
{p.candidats?.length ? <p className="text-xs text-gray-500">Candidats : {p.candidats.map((c) => c.prenom).join(', ')}</p> : <p className="text-xs text-gray-500">Aucun candidat.</p>}
|
||||
{posteMode === 'proposer' && <MembreSelect label="Membre" value={membreProposeId} onChange={setMembreProposeId} membres={membres} />}
|
||||
{posteMode && <textarea value={motivation} onChange={(e) => setMotivation(e.target.value)} rows={2} placeholder="Motivation optionnelle" className={`${INPUT} resize-none text-sm`} />}
|
||||
{posteMode ? (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button onClick={() => soumettreCandidature(p.id)} className="p-2 bg-green-50 text-green-700 rounded-xl text-sm font-bold">Soumettre</button>
|
||||
<button onClick={() => { setPosteMode(''); setMotivation(''); }} className="p-2 bg-gray-100 text-gray-600 rounded-xl text-sm font-bold">Annuler</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button onClick={() => setPosteMode('postuler')} className="p-2 bg-yellow-50 text-yellow-700 rounded-xl text-sm font-bold">Postuler</button>
|
||||
<button onClick={() => setPosteMode('proposer')} className="p-2 bg-blue-50 text-blue-700 rounded-xl text-sm font-bold">Proposer</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{action === 'Décisions' && (
|
||||
<Card titre="Décisions">
|
||||
<Champ label="Objet"><input value={nouvelleProp.sujet} onChange={(e) => setNouvelleProp({ ...nouvelleProp, sujet: e.target.value })} className={INPUT} placeholder="Nouvelle proposition" /></Champ>
|
||||
<Champ label="Détails"><textarea value={nouvelleProp.description} onChange={(e) => setNouvelleProp({ ...nouvelleProp, description: e.target.value })} rows={2} className={`${INPUT} resize-none text-sm`} /></Champ>
|
||||
<button onClick={soumettreProposition} className="w-full p-3 bg-aa-blue text-white rounded-xl text-sm font-bold">Soumettre la proposition</button>
|
||||
<div className="space-y-2 pt-2 border-t border-gray-100">
|
||||
{propositions.filter((p) => ['proposee', 'secondee', 'rejetee'].includes(p.statut)).map((p) => (
|
||||
<DecisionItem key={p.id} p={p} membres={membres} estExecutif={estExecutif} form={resolutionForm[p.id] || {}} setForm={(champ, valeur) => setResolutionForm({ ...resolutionForm, [p.id]: { ...(resolutionForm[p.id] || {}), [champ]: valeur } })} seconderPour={seconderPour[p.id] || ''} setSeconderPour={(v) => setSeconderPour({ ...seconderPour, [p.id]: v })} seconder={seconder} voter={voter} />
|
||||
))}
|
||||
{propositions.filter((p) => ['proposee', 'secondee', 'rejetee'].includes(p.statut)).length === 0 && <p className="text-sm text-gray-500">Aucune proposition à traiter.</p>}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RapportAssemblee({ date, reunion, pvJour, pvPrecedent, propositions, resolutionsJour, postesVacants }) {
|
||||
const contenuAuto = textePvAuto(pvPrecedent, propositions, resolutionsJour, postesVacants);
|
||||
return (
|
||||
<div className="bg-white rounded-2xl p-4 shadow-sm print:shadow-none print:rounded-none print:p-0">
|
||||
<h2 className="text-xl font-bold text-gray-900">Rapport d'assemblée d'affaires</h2>
|
||||
<p className="text-sm text-gray-500">{labelDateReunion(date)}</p>
|
||||
<Section titre="Présences">
|
||||
{reunion?.presences?.length ? reunion.presences.filter((p) => p.present).map((p) => <li key={p.membre_id}>{p.membre_prenom}</li>) : <li>Présences à cocher sur papier</li>}
|
||||
</Section>
|
||||
<Section titre="Réflexions en cours">
|
||||
{propositions.length ? propositions.map((p) => <li key={p.id}>{p.sujet} ({p.proposeur_prenom} / {p.secondeur_prenom || 'à confirmer'})</li>) : <li>Aucune proposition secondée non résolue</li>}
|
||||
</Section>
|
||||
<Section titre="Décisions prises">
|
||||
{resolutionsJour.length ? resolutionsJour.map((r) => <li key={r.id}>{r.numero_resolution} - {r.sujet}</li>) : <li>Aucune décision enregistrée pour cette date</li>}
|
||||
</Section>
|
||||
<Section titre="Postes disponibles">
|
||||
{postesVacants.length ? postesVacants.map((p) => <li key={p.id}>{p.nom}</li>) : <li>Aucun poste vacant</li>}
|
||||
</Section>
|
||||
<div className="mt-4">
|
||||
<h3 className="font-bold text-gray-800">Procès-verbal</h3>
|
||||
<p className="text-sm whitespace-pre-wrap mt-1">{pvJour?.contenu || contenuAuto}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function textePvAuto(pvPrecedent, propositions, resolutionsJour, postesVacants) {
|
||||
return [
|
||||
"Ouverture de l'assemblée.",
|
||||
'',
|
||||
pvPrecedent ? `Lecture du procès-verbal précédent (${new Date(pvPrecedent.date_reunion).toLocaleDateString('fr-CA')}).` : 'Lecture du procès-verbal précédent.',
|
||||
'',
|
||||
|
|
@ -141,123 +555,70 @@ export default function AssembleeAffaires() {
|
|||
'Postes disponibles:',
|
||||
...postesVacants.map((p) => `- ${p.nom}`),
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function moisPrecedent(dateStr) {
|
||||
const d = new Date(`${dateStr}T12:00:00`);
|
||||
d.setMonth(d.getMonth() - 1);
|
||||
const annee = d.getFullYear();
|
||||
const mois = d.getMonth() + 1;
|
||||
const noms = ['', 'Janvier', 'Février', 'Mars', 'Avril', 'Mai', 'Juin', 'Juillet', 'Août', 'Septembre', 'Octobre', 'Novembre', 'Décembre'];
|
||||
return { annee, mois, libelle: `${noms[mois]} ${annee}` };
|
||||
}
|
||||
|
||||
function DecisionItem({ p, membres, estExecutif, form, setForm, seconderPour, setSeconderPour, seconder, voter }) {
|
||||
const [label, cls] = STATUTS[p.statut] || STATUTS.proposee;
|
||||
const motif = (form.etant_donne_que || '').trim();
|
||||
const decision = (form.groupe_a_decide_de || '').trim();
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100">
|
||||
<PageHeader titre="Assemblée d'affaires" sousTitre="Assistant et rapport imprimable" />
|
||||
<div className="p-4 space-y-4">
|
||||
{msg && <div className="bg-green-50 text-green-700 p-3 rounded-xl text-center text-sm font-medium">{msg}</div>}
|
||||
{error && <div className="bg-red-50 text-red-600 p-3 rounded-xl text-center text-sm">{error}</div>}
|
||||
<div className="rounded-xl bg-gray-50 p-3 space-y-2">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="font-bold text-gray-800">{p.sujet}</p>
|
||||
{p.description && <p className="text-sm text-gray-500">{p.description}</p>}
|
||||
<p className="text-xs text-gray-400 mt-1">Proposée par {p.proposeur_prenom}{p.secondeur_prenom ? ` · Secondée par ${p.secondeur_prenom}` : ''}</p>
|
||||
</div>
|
||||
<span className={`text-xs px-2 py-1 rounded-full font-medium whitespace-nowrap ${cls}`}>{label}</span>
|
||||
</div>
|
||||
|
||||
<Card titre="Consulter le rapport">
|
||||
<Champ label="Date">
|
||||
<SelectDate value={dateSelection} onChange={setDateSelection} dates={choixDates} />
|
||||
</Champ>
|
||||
{p.statut === 'proposee' && (
|
||||
<div className="space-y-2">
|
||||
{estExecutif && <textarea value={form.etant_donne_que || ''} onChange={(e) => setForm('etant_donne_que', e.target.value)} placeholder="ÉTANT DONNÉ QUE..." rows={2} className={`${INPUT} resize-none text-sm`} />}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button onClick={imprimer} className="p-3 bg-gray-900 text-white rounded-xl text-sm font-bold">Imprimer</button>
|
||||
{pvPrecedent && (
|
||||
<button onClick={() => setShowPvPrecedent(!showPvPrecedent)} className="p-3 bg-blue-50 text-blue-700 rounded-xl text-sm font-bold border border-blue-100">
|
||||
PV précédent
|
||||
</button>
|
||||
)}
|
||||
{estExecutif && <button onClick={() => voter(p.id, 'rejetee')} disabled={!motif} className="p-2 bg-red-50 text-red-700 rounded-xl text-sm font-bold disabled:opacity-40">Rejeter</button>}
|
||||
<button onClick={() => seconder(p.id)} className="p-2 bg-blue-50 text-blue-700 rounded-xl text-sm font-bold">Je seconde</button>
|
||||
</div>
|
||||
</Card>
|
||||
{estExecutif && (
|
||||
<div className="grid grid-cols-[1fr_auto] gap-2">
|
||||
<MembreSelect label="Seconder pour" value={seconderPour} onChange={setSeconderPour} membres={membres} />
|
||||
<button onClick={() => seconder(p.id, seconderPour)} disabled={!seconderPour} className="px-4 bg-blue-600 text-white rounded-xl text-sm font-bold disabled:opacity-40">OK</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showPvPrecedent && pvPrecedent && (
|
||||
<Card titre="Procès-verbal précédent">
|
||||
<p className="text-xs text-gray-400">{new Date(pvPrecedent.date_reunion).toLocaleDateString('fr-CA')}</p>
|
||||
{estExecutif && !pvPrecedent.approuve && (
|
||||
<div className="space-y-2 pt-2 border-t border-gray-100">
|
||||
<textarea
|
||||
value={pvCorrection}
|
||||
onChange={(e) => setPvCorrection(e.target.value)}
|
||||
rows={8}
|
||||
className={`${INPUT} resize-none text-sm`}
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<select value={adoption.proposeur_id} onChange={(e) => setAdoption({ ...adoption, proposeur_id: e.target.value })} className={INPUT}>
|
||||
<option value="">Proposeur</option>
|
||||
{membres.map((m) => <option key={m.id} value={m.id}>{m.prenom}</option>)}
|
||||
</select>
|
||||
<select value={adoption.secondeur_id} onChange={(e) => setAdoption({ ...adoption, secondeur_id: e.target.value })} className={INPUT}>
|
||||
<option value="">Secondeur</option>
|
||||
{membres.map((m) => <option key={m.id} value={m.id}>{m.prenom}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<button onClick={adopterPvPrecedent} disabled={!adoption.proposeur_id || !adoption.secondeur_id}
|
||||
className="w-full p-3 bg-green-50 text-green-700 rounded-xl text-sm font-bold border border-green-100 disabled:opacity-40">
|
||||
Adopter
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{(!estExecutif || pvPrecedent.approuve) && (
|
||||
<p className="text-sm whitespace-pre-wrap text-gray-700">{pvPrecedent.contenu}</p>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="bg-white rounded-2xl p-5 shadow-sm print:shadow-none print:rounded-none print:p-0">
|
||||
<h2 className="text-xl font-bold text-gray-900">Rapport d’assemblée d’affaires</h2>
|
||||
<p className="text-sm text-gray-500">{dateSelection}</p>
|
||||
<Section titre="Présences">
|
||||
{reunion?.presences?.length ? reunion.presences.filter((p) => p.present).map((p) => <li key={p.membre_id}>{p.membre_prenom}</li>) : <li>Présences à cocher sur papier</li>}
|
||||
</Section>
|
||||
<Section titre="Réflexions en cours">
|
||||
{propositions.length ? propositions.map((p) => <li key={p.id}>{p.sujet} ({p.proposeur_prenom} / {p.secondeur_prenom || 'à confirmer'})</li>) : <li>Aucune proposition secondée non résolue</li>}
|
||||
</Section>
|
||||
<Section titre="Décisions prises">
|
||||
{resolutionsJour.length ? resolutionsJour.map((r) => <li key={r.id}>{r.numero_resolution} — {r.sujet}</li>) : <li>Aucune décision enregistrée pour cette date</li>}
|
||||
</Section>
|
||||
<Section titre="Postes disponibles">
|
||||
{postesVacants.length ? postesVacants.map((p) => <li key={p.id}>{p.nom}</li>) : <li>Aucun poste vacant</li>}
|
||||
</Section>
|
||||
<div className="mt-4">
|
||||
<h3 className="font-bold text-gray-800">Procès-verbal</h3>
|
||||
<p className="text-sm whitespace-pre-wrap mt-1">{pvJour?.contenu || textePvAuto}</p>
|
||||
{p.statut === 'secondee' && estExecutif && (
|
||||
<div className="space-y-2">
|
||||
<textarea value={form.etant_donne_que || ''} onChange={(e) => setForm('etant_donne_que', e.target.value)} placeholder="ÉTANT DONNÉ QUE..." rows={2} className={`${INPUT} resize-none text-sm`} />
|
||||
<textarea value={form.groupe_a_decide_de || ''} onChange={(e) => setForm('groupe_a_decide_de', e.target.value)} placeholder="LE GROUPE A DÉCIDÉ DE..." rows={2} className={`${INPUT} resize-none text-sm`} />
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button onClick={() => voter(p.id, 'rejetee')} disabled={!motif} className="p-2 bg-red-50 text-red-700 rounded-xl text-sm font-bold disabled:opacity-40">Rejeter</button>
|
||||
<button onClick={() => voter(p.id, 'adoptee')} disabled={!motif || !decision} className="p-2 bg-green-50 text-green-700 rounded-xl text-sm font-bold disabled:opacity-40">Transformer</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{estExecutif && (
|
||||
<>
|
||||
<Card titre="Créer ou renseigner l’assemblée">
|
||||
{reunions.length > 0 && (
|
||||
<Champ label="Assemblée existante">
|
||||
<select value={reunionId} onChange={(e) => setReunionId(e.target.value)} className={INPUT}>
|
||||
<option value="">Nouvelle assemblée</option>
|
||||
{reunions.map((r) => <option key={r.id} value={r.id}>{new Date(r.date_reunion).toLocaleDateString('fr-CA')}</option>)}
|
||||
</select>
|
||||
</Champ>
|
||||
)}
|
||||
{!reunionId && (
|
||||
<>
|
||||
<Champ label="Date"><SelectDate value={dateReunion} onChange={setDateReunion} dates={choixDates} /></Champ>
|
||||
<Champ label="Note"><input value={note} onChange={(e) => setNote(e.target.value)} placeholder="Note optionnelle" className={INPUT} /></Champ>
|
||||
<button onClick={creerRapport} className="w-full p-3 bg-indigo-50 text-indigo-700 rounded-xl text-sm font-bold border border-indigo-100">Créer le rapport d’assemblée</button>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card titre={`Présences (${nbPresents}/${membres.length})`}>
|
||||
<div className="space-y-1">{membres.map((m) => (
|
||||
<button key={m.id} onClick={() => toggle(m.id)} className={`w-full flex items-center gap-3 p-3 rounded-xl transition ${presencesMap[m.id] ? 'bg-green-50 border border-green-200' : 'bg-gray-50 border border-gray-100 opacity-50'}`}>
|
||||
<span className={`w-6 h-6 rounded-lg flex items-center justify-center text-sm font-bold ${presencesMap[m.id] ? 'bg-green-500 text-white' : 'bg-gray-200 text-gray-400'}`}>{presencesMap[m.id] ? '✓' : ''}</span>
|
||||
<span className="font-medium text-gray-800">{m.prenom}</span>
|
||||
</button>
|
||||
))}</div>
|
||||
</Card>
|
||||
|
||||
<Card titre="Procès-verbal">
|
||||
<textarea value={contenu} onChange={(e) => setContenu(e.target.value)} rows={10} placeholder={textePvAuto} className={`${INPUT} resize-none text-sm`} />
|
||||
</Card>
|
||||
|
||||
<button onClick={enregistrer} disabled={loading} className="w-full p-5 bg-indigo-600 text-white rounded-2xl text-lg font-bold disabled:opacity-40 active:scale-[0.97] transition shadow-lg">{loading ? '...' : 'Enregistrer l’assemblée'}</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function boutonAction(actif) {
|
||||
return `p-3 rounded-xl text-sm font-bold ${actif ? 'bg-aa-blue text-white' : 'bg-gray-50 text-gray-700'}`;
|
||||
}
|
||||
|
||||
function heure(dateIso) {
|
||||
return dateIso ? new Date(dateIso).toLocaleTimeString('fr-CA', { hour: '2-digit', minute: '2-digit' }) : '--:--';
|
||||
}
|
||||
|
||||
function Card({ titre, children }) {
|
||||
return <div className="bg-white rounded-2xl p-4 shadow-sm space-y-3"><h2 className="font-bold text-gray-800">{titre}</h2>{children}</div>;
|
||||
}
|
||||
|
|
@ -280,3 +641,12 @@ function SelectDate({ value, onChange, dates }) {
|
|||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
function MembreSelect({ label, value, onChange, membres }) {
|
||||
return (
|
||||
<select value={value} onChange={(e) => onChange(e.target.value)} className={INPUT}>
|
||||
<option value="">{label}</option>
|
||||
{membres.map((m) => <option key={m.id} value={m.id}>{m.prenom}</option>)}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
56
frontend/src/pages/AssembleeInvite.jsx
Normal file
56
frontend/src/pages/AssembleeInvite.jsx
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import { labelDateReunion } from '../utils/datesReunion';
|
||||
|
||||
export default function AssembleeInvite() {
|
||||
const code = window.location.pathname.split('/').filter(Boolean).pop();
|
||||
const [data, setData] = useState(null);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`/api/presences/invite/${code}`)
|
||||
.then(async (res) => {
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body.detail || 'Invitation invalide');
|
||||
}
|
||||
return res.json();
|
||||
})
|
||||
.then(setData)
|
||||
.catch((e) => setError(e.message));
|
||||
}, [code]);
|
||||
|
||||
if (error) {
|
||||
return <div className="min-h-screen bg-gray-100 p-4 flex items-center justify-center"><div className="bg-white rounded-2xl p-5 shadow-sm text-center text-red-600">{error}</div></div>;
|
||||
}
|
||||
if (!data) return <div className="min-h-screen bg-gray-100 p-4 flex items-center justify-center text-gray-500">Chargement...</div>;
|
||||
|
||||
const date = data.assemblee.date_reunion.slice(0, 10);
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100">
|
||||
<div className="bg-aa-blue text-white px-4 pt-4 pb-5 rounded-b-2xl shadow-lg">
|
||||
<p className="text-blue-200 text-xs">{data.groupe.nom}</p>
|
||||
<h1 className="text-xl font-bold">Assemblée d'affaires</h1>
|
||||
<p className="text-sm text-blue-100 mt-1">{labelDateReunion(date)}</p>
|
||||
</div>
|
||||
<div className="p-4 space-y-3">
|
||||
<Section titre="Réflexions et propositions">
|
||||
{data.propositions.length ? data.propositions.map((p) => <li key={p.id}>{p.sujet} ({p.statut})</li>) : <li>Aucune proposition à traiter.</li>}
|
||||
</Section>
|
||||
<Section titre="Décisions du jour">
|
||||
{data.decisions_du_jour.length ? data.decisions_du_jour.map((d, i) => <li key={i}>{d.numero_resolution || ''} {d.decision}</li>) : <li>Aucune décision enregistrée.</li>}
|
||||
</Section>
|
||||
<Section titre="Postes disponibles">
|
||||
{data.postes_disponibles.length ? data.postes_disponibles.map((p) => <li key={p.id}>{p.nom}</li>) : <li>Aucun poste vacant.</li>}
|
||||
</Section>
|
||||
<div className="bg-white rounded-2xl p-4 shadow-sm">
|
||||
<h2 className="font-bold text-gray-800">Procès-verbal</h2>
|
||||
<p className="text-sm text-gray-600 whitespace-pre-wrap mt-2">{data.pv?.contenu || "Le procès-verbal sera renseigné pendant l'assemblée."}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({ titre, children }) {
|
||||
return <div className="bg-white rounded-2xl p-4 shadow-sm"><h2 className="font-bold text-gray-800">{titre}</h2><ul className="list-disc ml-5 text-sm text-gray-600 mt-2 space-y-1">{children}</ul></div>;
|
||||
}
|
||||
|
|
@ -14,6 +14,16 @@ export default function GererMembres() {
|
|||
const [expiration, setExpiration] = useState(null);
|
||||
const [inviteLoading, setInviteLoading] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [createLoading, setCreateLoading] = useState(false);
|
||||
const [createForm, setCreateForm] = useState({
|
||||
prenom: '',
|
||||
nom: '',
|
||||
telephone: '',
|
||||
courriel: '',
|
||||
pin: '',
|
||||
date_abstinence: '',
|
||||
});
|
||||
|
||||
const charger = async () => {
|
||||
setLoading(true);
|
||||
|
|
@ -76,6 +86,26 @@ export default function GererMembres() {
|
|||
} catch (e) { alert(e.message); }
|
||||
};
|
||||
|
||||
const creerCompte = async () => {
|
||||
setCreateLoading(true);
|
||||
try {
|
||||
const payload = {
|
||||
...createForm,
|
||||
nom: createForm.nom.trim() || null,
|
||||
date_abstinence: createForm.date_abstinence || null,
|
||||
};
|
||||
const res = await api.post('/membres/', payload);
|
||||
setMsg(`Compte créé pour ${res.prenom}`);
|
||||
setShowCreate(false);
|
||||
setCreateForm({ prenom: '', nom: '', telephone: '', courriel: '', pin: '', date_abstinence: '' });
|
||||
await charger();
|
||||
setTimeout(() => setMsg(''), 3000);
|
||||
} catch (e) {
|
||||
alert(e.message);
|
||||
}
|
||||
setCreateLoading(false);
|
||||
};
|
||||
|
||||
// Vue détail d'un membre
|
||||
if (detail) {
|
||||
return (
|
||||
|
|
@ -197,6 +227,32 @@ export default function GererMembres() {
|
|||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-2xl p-4 shadow-sm space-y-3">
|
||||
<button onClick={() => setShowCreate(!showCreate)}
|
||||
className="w-full flex items-center justify-between font-bold text-gray-800">
|
||||
<span>Créer un compte membre</span>
|
||||
<span className="text-gray-400">{showCreate ? '−' : '+'}</span>
|
||||
</button>
|
||||
{showCreate && (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Champ label="Prénom"><input value={createForm.prenom} onChange={(e) => setCreateForm({ ...createForm, prenom: e.target.value })} className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue" /></Champ>
|
||||
<Champ label="Nom"><input value={createForm.nom} onChange={(e) => setCreateForm({ ...createForm, nom: e.target.value })} className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue" /></Champ>
|
||||
</div>
|
||||
<Champ label="Téléphone"><input value={createForm.telephone} onChange={(e) => setCreateForm({ ...createForm, telephone: e.target.value })} className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue" /></Champ>
|
||||
<Champ label="Courriel"><input type="email" value={createForm.courriel} onChange={(e) => setCreateForm({ ...createForm, courriel: e.target.value })} className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue" /></Champ>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Champ label="PIN initial"><input inputMode="numeric" maxLength={4} value={createForm.pin} onChange={(e) => setCreateForm({ ...createForm, pin: e.target.value.replace(/\D/g, '').slice(0, 4) })} className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue" /></Champ>
|
||||
<Champ label="Date d'abstinence"><input type="date" value={createForm.date_abstinence} onChange={(e) => setCreateForm({ ...createForm, date_abstinence: e.target.value })} className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue" /></Champ>
|
||||
</div>
|
||||
<button onClick={creerCompte} disabled={createLoading || !createForm.prenom || !createForm.telephone || !createForm.courriel || createForm.pin.length !== 4}
|
||||
className="w-full p-3 bg-aa-blue text-white rounded-xl font-bold disabled:opacity-40">
|
||||
{createLoading ? '...' : 'Créer le compte'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<p className="text-center text-gray-400 py-8">Chargement...</p>
|
||||
) : (
|
||||
|
|
@ -215,3 +271,7 @@ export default function GererMembres() {
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Champ({ label, children }) {
|
||||
return <label className="block"><span className="text-xs text-gray-400 block mb-1">{label}</span>{children}</label>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,21 +9,21 @@ const CATEGORIES = [
|
|||
];
|
||||
|
||||
const MODULES = [
|
||||
['S0', 'Rencontre hebdomadaire'], ['M0', "Assemblée d'affaires"],
|
||||
['S1', 'Collecte 7e'], ['C5', 'Gouvernance'],
|
||||
['S0', 'Rencontres'], ['M0', "Assemblée d'affaires"],
|
||||
['C5', 'Gouvernance'],
|
||||
['S1T', 'Trésorerie'], ['M4', 'Donner au suivant'],
|
||||
['M1', 'Rédiger PV'], ['M2', 'Présences'], ['M3', 'Rapport RSG'],
|
||||
['S2', 'Remise jeton'], ['M6', 'Commander jetons'],
|
||||
['S3', 'Vente littérature'], ['M5', 'Ajuster inventaire'], ['S4', 'Rapport réunion'],
|
||||
['S3', 'Vente littérature'], ['M5', 'Ajuster inventaire'],
|
||||
['G2', 'Gestion membres'], ['G9', 'Paramètres'],
|
||||
['G11', 'Gestion mandats'], ['G12', 'Gestion postes'],
|
||||
];
|
||||
|
||||
const GROUPES_MODULES = [
|
||||
{ titre: 'Réunion', codes: ['S0', 'S1', 'S2', 'S3', 'S4'] },
|
||||
{ titre: 'Réunion', codes: ['S0'] },
|
||||
{ titre: 'Affaires', codes: ['M0', 'M1', 'M2', 'M3', 'C5'] },
|
||||
{ titre: 'Finances', codes: ['S1T', 'S5', 'M4'] },
|
||||
{ titre: 'Inventaire', codes: ['C4', 'M5', 'M6'] },
|
||||
{ titre: 'Inventaire', codes: ['M5', 'M6'] },
|
||||
{ titre: 'Administration', codes: ['G2', 'G9', 'G11', 'G12', 'G13'] },
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -2,23 +2,13 @@ import React, { useState, useEffect } from 'react';
|
|||
import { api } from '../stores/api';
|
||||
import PageHeader from '../components/PageHeader';
|
||||
|
||||
const MOIS_FR = [
|
||||
'', 'Janvier', 'Février', 'Mars', 'Avril', 'Mai', 'Juin',
|
||||
'Juillet', 'Août', 'Septembre', 'Octobre', 'Novembre', 'Décembre',
|
||||
];
|
||||
|
||||
export default function Rapports() {
|
||||
const [disponibles, setDisponibles] = useState([]);
|
||||
const [anneesResolutions, setAnneesResolutions] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [generating, setGenerating] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
api.get('/rapports/disponibles'),
|
||||
api.get('/rapports/resolutions/annees'),
|
||||
]).then(([dispo, annees]) => {
|
||||
setDisponibles(dispo);
|
||||
api.get('/rapports/resolutions/annees').then((annees) => {
|
||||
setAnneesResolutions(annees);
|
||||
setLoading(false);
|
||||
}).catch(() => setLoading(false));
|
||||
|
|
@ -44,8 +34,7 @@ export default function Rapports() {
|
|||
setGenerating(null);
|
||||
};
|
||||
|
||||
const rapportsMensuels = disponibles.filter((m) => m.reunions);
|
||||
const vide = rapportsMensuels.length === 0 && anneesResolutions.length === 0;
|
||||
const vide = anneesResolutions.length === 0;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100">
|
||||
|
|
@ -62,31 +51,6 @@ export default function Rapports() {
|
|||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Rapports mensuels */}
|
||||
{rapportsMensuels.map((m) => (
|
||||
<div key={`${m.annee}-${m.mois}`} className="bg-white rounded-2xl p-4 shadow-sm">
|
||||
<h3 className="font-bold text-gray-800 mb-3">
|
||||
{MOIS_FR[m.mois]} {m.annee}
|
||||
</h3>
|
||||
<div className="grid gap-2">
|
||||
{m.reunions && (
|
||||
<button
|
||||
onClick={() => telecharger(`reunions/${m.annee}/${m.mois}`, `reunions-${m.annee}-${m.mois}`, `rapport_reunions_${m.annee}_${String(m.mois).padStart(2,'0')}.pdf`)}
|
||||
disabled={generating === `reunions-${m.annee}-${m.mois}`}
|
||||
className="flex items-center gap-3 p-3 bg-purple-50 rounded-xl text-left active:scale-[0.98] transition border border-purple-200"
|
||||
>
|
||||
<span className="text-xl">🎤</span>
|
||||
<div className="flex-1">
|
||||
<p className="font-bold text-purple-800 text-sm">Résumé des réunions</p>
|
||||
<p className="text-xs text-purple-600">Thèmes, annonces, animateurs</p>
|
||||
</div>
|
||||
<span className="text-purple-400">{generating === `reunions-${m.annee}-${m.mois}` ? '⏳' : '↓'}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Registre des résolutions */}
|
||||
{anneesResolutions.length > 0 && (
|
||||
<div className="bg-white rounded-2xl p-4 shadow-sm">
|
||||
|
|
|
|||
|
|
@ -7,190 +7,368 @@ import { dateHeureReunionIso, datesReunion, dateLocale, labelDateReunion } from
|
|||
const TYPES_JETONS = [['desir', 'Désir'], ['24h', '24 heures'], ['1mois', '1 mois'], ['2mois', '2 mois'], ['3mois', '3 mois'], ['6mois', '6 mois'], ['9mois', '9 mois'], ['1an', '1 an'], ['multiple', 'Multiple']];
|
||||
const INPUT = 'w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue';
|
||||
|
||||
export default function ReunionHebdomadaire() {
|
||||
export default function ReunionHebdomadaire({ contexte = false }) {
|
||||
const { postes, groupe } = useStore();
|
||||
const estExecutif = postes.some((p) => ['RSG', 'RSG Substitut(e)', 'Trésorier(ière)', 'Secrétaire'].includes(p));
|
||||
const choixDates = datesReunion(groupe, { joursAvant: 365, joursApres: 180 });
|
||||
const estExecutif = postes.some((p) => ['Sysadmin', 'RSG', 'RSG Substitut(e)', 'Trésorier(ière)', 'Secrétaire'].includes(p));
|
||||
const choixDatesChrono = datesReunion(groupe, { joursAvant: 365, joursApres: 180 });
|
||||
const choixDates = [...choixDatesChrono].reverse();
|
||||
const prochaineDate = choixDatesChrono.find((d) => d >= dateLocale()) || choixDatesChrono[choixDatesChrono.length - 1] || dateLocale();
|
||||
const [membres, setMembres] = useState([]);
|
||||
const [animateurDefautId, setAnimateurDefautId] = useState('');
|
||||
const [catalogue, setCatalogue] = useState([]);
|
||||
const [reunions, setReunions] = useState([]);
|
||||
const [dateSelection, setDateSelection] = useState(dateLocale());
|
||||
const [dateReunion, setDateReunion] = useState(dateLocale());
|
||||
const [theme, setTheme] = useState('');
|
||||
const [annonces, setAnnonces] = useState('');
|
||||
const [note, setNote] = useState('');
|
||||
const [collecteMontant, setCollecteMontant] = useState('');
|
||||
const [detenteurId, setDetenteurId] = useState('');
|
||||
const [jetonType, setJetonType] = useState('');
|
||||
const [jetonMembreId, setJetonMembreId] = useState('');
|
||||
const [jetonAnnees, setJetonAnnees] = useState('');
|
||||
const [litteratureId, setLitteratureId] = useState('');
|
||||
const [quantite, setQuantite] = useState('1');
|
||||
const [venteMontant, setVenteMontant] = useState('');
|
||||
const [rencontres, setRencontres] = useState([]);
|
||||
const [ouverteId, setOuverteId] = useState(null);
|
||||
const [action, setAction] = useState('');
|
||||
const [msg, setMsg] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [rapportVisible, setRapportVisible] = useState(false);
|
||||
const [planifier, setPlanifier] = useState(false);
|
||||
const [plan, setPlan] = useState({ date: prochaineDate, animateur_id: '', theme: '', annonces: '', note: '' });
|
||||
const [collecte, setCollecte] = useState({ montant: '', detenteur_id: '' });
|
||||
const [vente, setVente] = useState({ type: 'litterature', litterature_id: '', type_jeton: 'desir', quantite: '1', montant: '', vendu_par_id: '' });
|
||||
const [jeton, setJeton] = useState({ type_jeton: '', membre_id: '', annees: '' });
|
||||
const [rapport, setRapport] = useState({ animateur_id: '', theme: '', annonces: '', note: '' });
|
||||
|
||||
const charger = async () => {
|
||||
try {
|
||||
const [m, l, r] = await Promise.all([
|
||||
api.get('/membres/actifs'),
|
||||
api.get('/litterature/'),
|
||||
api.get('/reunions/?limite=80'),
|
||||
api.get('/reunions/rencontres/details?limite=80'),
|
||||
]);
|
||||
let animateurId = '';
|
||||
if (estExecutif) {
|
||||
try {
|
||||
const affectations = await api.get('/postes/affectations/groupe');
|
||||
const affectationAnimateur = affectations.find((a) => (a.poste_nom || '').toLowerCase().includes('animateur'));
|
||||
animateurId = affectationAnimateur?.membre_id || '';
|
||||
} catch {
|
||||
animateurId = '';
|
||||
}
|
||||
}
|
||||
setMembres(m);
|
||||
setCatalogue(l);
|
||||
setReunions(r.filter((x) => x.type === 'reguliere'));
|
||||
if (m.length > 0 && !detenteurId) setDetenteurId(m[0].id);
|
||||
setRencontres(r);
|
||||
setAnimateurDefautId(animateurId);
|
||||
if (m.length > 0) {
|
||||
const fallbackAnimateur = animateurId || m[0].id;
|
||||
setPlan((f) => ({ ...f, animateur_id: f.animateur_id || fallbackAnimateur }));
|
||||
setRapport((f) => ({ ...f, animateur_id: f.animateur_id || fallbackAnimateur }));
|
||||
setCollecte((f) => ({ ...f, detenteur_id: f.detenteur_id || m[0].id }));
|
||||
setVente((f) => ({ ...f, vendu_par_id: f.vendu_par_id || m[0].id }));
|
||||
}
|
||||
} catch (e) { setError(e.message); }
|
||||
};
|
||||
|
||||
useEffect(() => { charger(); }, []);
|
||||
useEffect(() => {
|
||||
if (choixDates.length > 0) {
|
||||
if (!choixDates.includes(dateSelection)) setDateSelection(choixDates.find((d) => d >= dateLocale()) || choixDates[choixDates.length - 1]);
|
||||
if (!choixDates.includes(dateReunion)) setDateReunion(choixDates.find((d) => d >= dateLocale()) || choixDates[choixDates.length - 1]);
|
||||
}
|
||||
}, [groupe?.jour_reunion]);
|
||||
|
||||
const rapport = reunions.find((r) => r.date_reunion?.slice(0, 10) === dateSelection);
|
||||
const dateSimple = () => dateReunion;
|
||||
const flash = (texte) => { setMsg(texte); setTimeout(() => setMsg(''), 2500); };
|
||||
const dateRencontre = (r) => r.date_reunion?.slice(0, 10);
|
||||
const rencontreOuverte = rencontres.find((r) => r.id === ouverteId);
|
||||
|
||||
const ouvrir = (id) => {
|
||||
const prochaine = ouverteId === id ? null : id;
|
||||
setOuverteId(prochaine);
|
||||
setAction('');
|
||||
const r = rencontres.find((x) => x.id === id);
|
||||
const animateur = membres.find((m) => m.prenom === r?.animateur_prenom);
|
||||
setRapport({
|
||||
animateur_id: animateur?.id || animateurDefautId || membres[0]?.id || '',
|
||||
theme: r?.theme || '',
|
||||
annonces: (r?.annonces || []).join('\n'),
|
||||
note: r?.note || '',
|
||||
});
|
||||
};
|
||||
|
||||
const choisirLitterature = (id) => {
|
||||
setLitteratureId(id);
|
||||
const item = catalogue.find((c) => c.id === id);
|
||||
if (item?.prix_coutant) setVenteMontant((item.prix_coutant * parseInt(quantite || '1')).toFixed(2));
|
||||
const quantite = parseInt(vente.quantite || '1');
|
||||
setVente({
|
||||
...vente,
|
||||
litterature_id: id,
|
||||
montant: item?.prix_coutant ? (item.prix_coutant * quantite).toFixed(2) : vente.montant,
|
||||
});
|
||||
};
|
||||
|
||||
const changerQuantite = (valeur) => {
|
||||
setQuantite(valeur);
|
||||
const item = catalogue.find((c) => c.id === litteratureId);
|
||||
if (item?.prix_coutant && valeur) setVenteMontant((item.prix_coutant * parseInt(valeur)).toFixed(2));
|
||||
const item = catalogue.find((c) => c.id === vente.litterature_id);
|
||||
const qte = parseInt(valeur || '0');
|
||||
setVente({
|
||||
...vente,
|
||||
quantite: valeur,
|
||||
montant: vente.type === 'litterature' && item?.prix_coutant && qte > 0 ? (item.prix_coutant * qte).toFixed(2) : vente.montant,
|
||||
});
|
||||
};
|
||||
|
||||
const imprimer = () => window.print();
|
||||
|
||||
const enregistrer = async () => {
|
||||
setError('');
|
||||
setMsg('');
|
||||
const creerRencontre = async () => {
|
||||
setLoading(true);
|
||||
const faits = [];
|
||||
setError('');
|
||||
try {
|
||||
if (theme.trim() || annonces.trim() || note.trim()) {
|
||||
const annoncesList = annonces.split('\n').map((a) => a.trim()).filter(Boolean);
|
||||
await api.post('/reunions/', {
|
||||
type: 'reguliere',
|
||||
date_reunion: dateHeureReunionIso(dateReunion, groupe),
|
||||
theme: theme.trim() || 'Rencontre hebdomadaire',
|
||||
annonces: annoncesList.length ? annoncesList : null,
|
||||
note: note.trim() || null,
|
||||
});
|
||||
faits.push('rapport');
|
||||
}
|
||||
if (collecteMontant) {
|
||||
await api.post('/collectes/', { date_collecte: dateSimple(), montant: parseFloat(collecteMontant), detenteur_id: detenteurId });
|
||||
faits.push('collecte');
|
||||
}
|
||||
if (jetonType) {
|
||||
const body = { type_jeton: jetonType, date_remise: dateSimple() };
|
||||
if (jetonMembreId) body.membre_id = jetonMembreId;
|
||||
if (jetonType === 'multiple') body.annees = parseInt(jetonAnnees);
|
||||
await api.post('/jetons/', body);
|
||||
faits.push('jeton');
|
||||
}
|
||||
if (litteratureId) {
|
||||
await api.post('/ventes-litterature/', { litterature_id: litteratureId, quantite: parseInt(quantite), montant: parseFloat(venteMontant), date_vente: dateSimple() });
|
||||
faits.push('littérature');
|
||||
}
|
||||
if (faits.length === 0) setError('Remplissez au moins une section');
|
||||
else {
|
||||
setMsg(`Enregistré : ${faits.join(', ')}`);
|
||||
setDateSelection(dateSimple());
|
||||
await charger();
|
||||
}
|
||||
await api.post('/reunions/', {
|
||||
type: 'reguliere',
|
||||
date_reunion: dateHeureReunionIso(plan.date, groupe),
|
||||
animateur_id: plan.animateur_id || animateurDefautId || null,
|
||||
theme: plan.theme.trim() || 'Rencontre hebdomadaire',
|
||||
annonces: plan.annonces.split('\n').map((a) => a.trim()).filter(Boolean) || null,
|
||||
note: plan.note.trim() || null,
|
||||
});
|
||||
setPlanifier(false);
|
||||
setPlan({ date: prochaineDate, animateur_id: animateurDefautId, theme: '', annonces: '', note: '' });
|
||||
flash('Rencontre planifiée');
|
||||
await charger();
|
||||
} catch (e) { setError(e.message); }
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const enregistrerCollecte = async () => {
|
||||
if (!rencontreOuverte || !collecte.montant || !collecte.detenteur_id) { setError('Montant et détenteur requis'); return; }
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
await api.post('/collectes/', {
|
||||
reunion_id: rencontreOuverte.id,
|
||||
date_collecte: dateRencontre(rencontreOuverte),
|
||||
montant: parseFloat(collecte.montant),
|
||||
detenteur_id: collecte.detenteur_id,
|
||||
});
|
||||
setCollecte({ ...collecte, montant: '' });
|
||||
flash('Collecte saisie');
|
||||
await charger();
|
||||
} catch (e) { setError(e.message); }
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const enregistrerVente = async () => {
|
||||
if (!rencontreOuverte || !vente.montant || !vente.vendu_par_id) { setError('Montant et vendeur requis'); return; }
|
||||
const quantite = parseInt(vente.quantite || '0');
|
||||
if (quantite <= 0) { setError('Quantité invalide'); return; }
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
if (vente.type === 'litterature') {
|
||||
if (!vente.litterature_id) { setError('Sélectionnez un titre'); setLoading(false); return; }
|
||||
await api.post('/ventes-litterature/', {
|
||||
reunion_id: rencontreOuverte.id,
|
||||
litterature_id: vente.litterature_id,
|
||||
quantite,
|
||||
montant: parseFloat(vente.montant),
|
||||
date_vente: dateRencontre(rencontreOuverte),
|
||||
vendu_par_id: vente.vendu_par_id,
|
||||
});
|
||||
} else {
|
||||
await api.post('/jetons/ventes', {
|
||||
reunion_id: rencontreOuverte.id,
|
||||
type_jeton: vente.type_jeton,
|
||||
quantite,
|
||||
montant: parseFloat(vente.montant),
|
||||
date_vente: dateRencontre(rencontreOuverte),
|
||||
vendu_par_id: vente.vendu_par_id,
|
||||
});
|
||||
}
|
||||
setVente({ ...vente, litterature_id: '', quantite: '1', montant: '' });
|
||||
flash('Vente saisie');
|
||||
await charger();
|
||||
} catch (e) { setError(e.message); }
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const enregistrerJeton = async () => {
|
||||
if (!rencontreOuverte || !jeton.type_jeton) { setError('Sélectionnez un type de jeton'); return; }
|
||||
if (jeton.type_jeton !== 'desir' && !jeton.membre_id) { setError('Sélectionnez un membre'); return; }
|
||||
if (jeton.type_jeton === 'multiple' && (!jeton.annees || parseInt(jeton.annees) < 2)) { setError("Nombre d'années requis"); return; }
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const body = {
|
||||
reunion_id: rencontreOuverte.id,
|
||||
type_jeton: jeton.type_jeton,
|
||||
date_remise: dateRencontre(rencontreOuverte),
|
||||
};
|
||||
if (jeton.membre_id) body.membre_id = jeton.membre_id;
|
||||
if (jeton.type_jeton === 'multiple') body.annees = parseInt(jeton.annees);
|
||||
await api.post('/jetons/', body);
|
||||
setJeton({ type_jeton: '', membre_id: '', annees: '' });
|
||||
flash('Jeton/gâteau comptabilisé');
|
||||
await charger();
|
||||
} catch (e) { setError(e.message); }
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const enregistrerRapport = async () => {
|
||||
if (!rencontreOuverte) return;
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
await api.patch(`/reunions/${rencontreOuverte.id}`, {
|
||||
type: 'reguliere',
|
||||
date_reunion: rencontreOuverte.date_reunion,
|
||||
animateur_id: rapport.animateur_id || null,
|
||||
theme: rapport.theme.trim() || 'Rencontre hebdomadaire',
|
||||
annonces: rapport.annonces.split('\n').map((a) => a.trim()).filter(Boolean) || null,
|
||||
note: rapport.note.trim() || null,
|
||||
});
|
||||
flash('Rapport enregistré');
|
||||
await charger();
|
||||
} catch (e) { setError(e.message); }
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const telechargerRapport = async (rencontre) => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const token = api.getToken();
|
||||
const res = await fetch(`/api/reunions/${rencontre.id}/rapport.pdf`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!res.ok) throw new Error('Erreur de génération du rapport PDF');
|
||||
const blob = await res.blob();
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = `rapport_rencontre_${dateRencontre(rencontre)}.pdf`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(a.href);
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const heure = (dateIso) => (dateIso ? new Date(dateIso).toLocaleTimeString('fr-CA', { hour: '2-digit', minute: '2-digit' }) : '—');
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100">
|
||||
<PageHeader titre="Rencontre hebdomadaire" sousTitre="Assistant et rapport imprimable" />
|
||||
<PageHeader titre="Rencontres" sousTitre="Saisies et rapports des rencontres hebdomadaires" retour={!contexte} />
|
||||
<div className="p-4 space-y-4">
|
||||
{msg && <div className="bg-green-50 text-green-700 p-3 rounded-xl text-center text-sm font-medium">{msg}</div>}
|
||||
{error && <div className="bg-red-50 text-red-600 p-3 rounded-xl text-center text-sm">{error}</div>}
|
||||
|
||||
<Card titre="Rapport imprimable">
|
||||
<Champ label="Date">
|
||||
<SelectDate value={dateSelection} onChange={setDateSelection} dates={choixDates} />
|
||||
</Champ>
|
||||
<button onClick={() => setRapportVisible(true)} className="w-full p-3 bg-aa-blue text-white rounded-xl text-sm font-bold">
|
||||
Produire le rapport
|
||||
</button>
|
||||
{rapportVisible && <button onClick={imprimer} className="w-full p-3 bg-gray-900 text-white rounded-xl text-sm font-bold">Imprimer format lettre</button>}
|
||||
</Card>
|
||||
|
||||
{rapportVisible && <div className="bg-white rounded-2xl p-5 shadow-sm print:shadow-none print:rounded-none print:p-0">
|
||||
<h2 className="text-xl font-bold text-gray-900">Rapport de réunion hebdomadaire</h2>
|
||||
<p className="text-sm text-gray-500">{dateSelection}</p>
|
||||
{rapport ? (
|
||||
<div className="mt-4 space-y-3 text-sm">
|
||||
<p><strong>Thème :</strong> {rapport.theme || '____________________________'}</p>
|
||||
<div>
|
||||
<strong>Annonces :</strong>
|
||||
{(rapport.annonces || []).length > 0 ? (
|
||||
<ul className="list-disc ml-5 mt-1">{rapport.annonces.map((a, i) => <li key={i}>{a}</li>)}</ul>
|
||||
) : <p className="mt-1 whitespace-pre-wrap">{"\n\n\n"}</p>}
|
||||
</div>
|
||||
<p><strong>Notes :</strong> {rapport.note || '____________________________________________'}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-4 space-y-4 text-sm">
|
||||
<p><strong>Thème :</strong> ____________________________________________</p>
|
||||
<p><strong>Annonces :</strong></p>
|
||||
<div className="h-28 border border-gray-200 rounded-xl" />
|
||||
<p><strong>Notes :</strong></p>
|
||||
<div className="h-20 border border-gray-200 rounded-xl" />
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-4 mt-6 text-sm">
|
||||
<p>Collecte : __________ $</p>
|
||||
<p>Détenteur : _______________</p>
|
||||
<p>Jeton : __________________</p>
|
||||
<p>Littérature : __________ $</p>
|
||||
</div>
|
||||
</div>}
|
||||
|
||||
{estExecutif && (
|
||||
<>
|
||||
<Card titre="Renseigner le rapport">
|
||||
<Champ label="Date"><SelectDate value={dateReunion} onChange={setDateReunion} dates={choixDates} /></Champ>
|
||||
<Champ label="Thème"><input value={theme} onChange={(e) => setTheme(e.target.value)} placeholder="Thème abordé" className={INPUT} /></Champ>
|
||||
<Champ label="Annonces"><textarea value={annonces} onChange={(e) => setAnnonces(e.target.value)} rows={3} placeholder="Une annonce par ligne" className={`${INPUT} resize-none`} /></Champ>
|
||||
<Champ label="Notes"><textarea value={note} onChange={(e) => setNote(e.target.value)} rows={2} placeholder="Notes optionnelles" className={`${INPUT} resize-none`} /></Champ>
|
||||
</Card>
|
||||
<Card titre="Saisies liées">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Champ label="Collecte"><input type="number" inputMode="decimal" step="0.01" value={collecteMontant} onChange={(e) => setCollecteMontant(e.target.value)} placeholder="0.00" className={INPUT} /></Champ>
|
||||
<Champ label="Détenteur"><select value={detenteurId} onChange={(e) => setDetenteurId(e.target.value)} className={INPUT}>{membres.map((m) => <option key={m.id} value={m.id}>{m.prenom}</option>)}</select></Champ>
|
||||
</div>
|
||||
<Champ label="Jeton"><select value={jetonType} onChange={(e) => setJetonType(e.target.value)} className={INPUT}><option value="">Aucun jeton</option>{TYPES_JETONS.map(([value, label]) => <option key={value} value={value}>{label}</option>)}</select></Champ>
|
||||
<Champ label="Membre jeton"><select value={jetonMembreId} onChange={(e) => setJetonMembreId(e.target.value)} className={INPUT}><option value="">Anonyme / non applicable</option>{membres.map((m) => <option key={m.id} value={m.id}>{m.prenom}</option>)}</select></Champ>
|
||||
{jetonType === 'multiple' && <Champ label="Années"><input type="number" min="2" value={jetonAnnees} onChange={(e) => setJetonAnnees(e.target.value)} className={INPUT} /></Champ>}
|
||||
<Champ label="Littérature"><select value={litteratureId} onChange={(e) => choisirLitterature(e.target.value)} className={INPUT}><option value="">Aucune vente</option>{catalogue.map((c) => <option key={c.id} value={c.id}>{c.titre} ({c.quantite_stock})</option>)}</select></Champ>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Champ label="Quantité"><input type="number" min="1" value={quantite} onChange={(e) => changerQuantite(e.target.value)} className={INPUT} /></Champ>
|
||||
<Champ label="Montant"><input type="number" inputMode="decimal" step="0.01" value={venteMontant} onChange={(e) => setVenteMontant(e.target.value)} placeholder="0.00" className={INPUT} /></Champ>
|
||||
</div>
|
||||
</Card>
|
||||
<button onClick={enregistrer} disabled={loading} className="w-full p-5 bg-aa-blue text-white rounded-2xl text-lg font-bold disabled:opacity-40 active:scale-[0.97] transition shadow-lg">{loading ? '...' : 'Enregistrer les sections remplies'}</button>
|
||||
</>
|
||||
<button onClick={() => setPlanifier(!planifier)} className="w-full p-3 bg-aa-blue text-white rounded-xl text-sm font-bold">
|
||||
Planifier une rencontre
|
||||
</button>
|
||||
)}
|
||||
|
||||
{planifier && (
|
||||
<Card titre="Nouvelle rencontre">
|
||||
<Champ label="Date"><SelectDate value={plan.date} onChange={(v) => setPlan({ ...plan, date: v })} dates={choixDates} /></Champ>
|
||||
<Champ label="Animé par"><MembreSelect value={plan.animateur_id} onChange={(v) => setPlan({ ...plan, animateur_id: v })} membres={membres} /></Champ>
|
||||
<Champ label="Sujet"><input value={plan.theme} onChange={(e) => setPlan({ ...plan, theme: e.target.value })} className={INPUT} /></Champ>
|
||||
<Champ label="Annonces"><textarea value={plan.annonces} onChange={(e) => setPlan({ ...plan, annonces: e.target.value })} rows={3} className={`${INPUT} resize-none`} /></Champ>
|
||||
<Champ label="Note"><textarea value={plan.note} onChange={(e) => setPlan({ ...plan, note: e.target.value })} rows={2} className={`${INPUT} resize-none`} /></Champ>
|
||||
<button onClick={creerRencontre} disabled={loading} className="w-full p-3 bg-green-600 text-white rounded-xl text-sm font-bold disabled:opacity-40">Créer</button>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
{rencontres.map((r) => (
|
||||
<div key={r.id} className="bg-white rounded-2xl shadow-sm overflow-hidden">
|
||||
<div className="flex items-start gap-2 p-4">
|
||||
<button onClick={() => ouvrir(r.id)} className="min-w-0 flex-1 text-left active:bg-gray-50">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="font-extrabold text-gray-900">{labelDateReunion(dateRencontre(r))} · {heure(r.date_reunion)}</p>
|
||||
<p className="text-sm text-gray-500 truncate">Sujet : {r.theme || 'Rencontre hebdomadaire'} · Animé par : {r.animateur_prenom || '—'}</p>
|
||||
</div>
|
||||
<span className="text-xl text-gray-300">{ouverteId === r.id ? '⌃' : '⌄'}</span>
|
||||
</div>
|
||||
</button>
|
||||
<button onClick={() => telechargerRapport(r)} disabled={loading}
|
||||
className="shrink-0 rounded-xl bg-gray-900 px-3 py-2 text-xs font-bold text-white disabled:opacity-40">
|
||||
Rapport PDF
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{ouverteId === r.id && (
|
||||
<div className="p-4 pt-0 space-y-3 border-t border-gray-100">
|
||||
<Resume rencontre={r} />
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{['Collecte', 'Ventes', 'Jetons/Gâteaux'].map((nom) => (
|
||||
<button key={nom} onClick={() => setAction(action === nom ? '' : nom)}
|
||||
className={`p-3 rounded-xl text-sm font-bold ${action === nom ? 'bg-aa-blue text-white' : 'bg-gray-50 text-gray-700'}`}>
|
||||
{nom}
|
||||
</button>
|
||||
))}
|
||||
{estExecutif && (
|
||||
<button onClick={() => setAction(action === 'Modifier' ? '' : 'Modifier')}
|
||||
className={`p-3 rounded-xl text-sm font-bold ${action === 'Modifier' ? 'bg-aa-blue text-white' : 'bg-gray-50 text-gray-700'}`}>
|
||||
Modifier
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{action === 'Collecte' && (
|
||||
<Card titre="Collecte">
|
||||
{r.collecte ? <p className="text-sm text-gray-500">Collecte déjà saisie pour cette rencontre.</p> : (
|
||||
<>
|
||||
<Champ label="Montant"><input type="number" inputMode="decimal" step="0.01" value={collecte.montant} onChange={(e) => setCollecte({ ...collecte, montant: e.target.value })} className={INPUT} /></Champ>
|
||||
<Champ label="Détenteur de l'argent"><MembreSelect value={collecte.detenteur_id} onChange={(v) => setCollecte({ ...collecte, detenteur_id: v })} membres={membres} /></Champ>
|
||||
<button onClick={enregistrerCollecte} disabled={loading} className="w-full p-3 bg-green-600 text-white rounded-xl text-sm font-bold disabled:opacity-40">Saisir la collecte</button>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{action === 'Ventes' && (
|
||||
<Card titre="Ventes">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{['litterature', 'jeton'].map((type) => <button key={type} onClick={() => setVente({ ...vente, type })} className={`p-3 rounded-xl text-sm font-bold ${vente.type === type ? 'bg-aa-blue text-white' : 'bg-gray-50 text-gray-600'}`}>{type === 'litterature' ? 'Littérature' : 'Jeton'}</button>)}
|
||||
</div>
|
||||
{vente.type === 'litterature' ? (
|
||||
<Champ label="Titre"><select value={vente.litterature_id} onChange={(e) => choisirLitterature(e.target.value)} className={INPUT}><option value="">Sélectionner</option>{catalogue.map((c) => <option key={c.id} value={c.id}>{c.titre} ({c.quantite_stock})</option>)}</select></Champ>
|
||||
) : (
|
||||
<Champ label="Type de jeton"><select value={vente.type_jeton} onChange={(e) => setVente({ ...vente, type_jeton: e.target.value })} className={INPUT}>{TYPES_JETONS.map(([value, label]) => <option key={value} value={value}>{label}</option>)}</select></Champ>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Champ label="Quantité"><input type="number" min="1" value={vente.quantite} onChange={(e) => changerQuantite(e.target.value)} className={INPUT} /></Champ>
|
||||
<Champ label="Montant"><input type="number" inputMode="decimal" step="0.01" value={vente.montant} onChange={(e) => setVente({ ...vente, montant: e.target.value })} className={INPUT} /></Champ>
|
||||
</div>
|
||||
<Champ label="Vendu par"><MembreSelect value={vente.vendu_par_id} onChange={(v) => setVente({ ...vente, vendu_par_id: v })} membres={membres} /></Champ>
|
||||
<button onClick={enregistrerVente} disabled={loading} className="w-full p-3 bg-green-600 text-white rounded-xl text-sm font-bold disabled:opacity-40">Saisir la vente</button>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{action === 'Jetons/Gâteaux' && (
|
||||
<Card titre="Jetons/Gâteaux">
|
||||
<Champ label="Type"><select value={jeton.type_jeton} onChange={(e) => setJeton({ ...jeton, type_jeton: e.target.value })} className={INPUT}><option value="">Sélectionner</option>{TYPES_JETONS.map(([value, label]) => <option key={value} value={value}>{label}</option>)}</select></Champ>
|
||||
<Champ label="Membre"><MembreSelect value={jeton.membre_id} onChange={(v) => setJeton({ ...jeton, membre_id: v })} membres={membres} empty="Anonyme / non applicable" /></Champ>
|
||||
{jeton.type_jeton === 'multiple' && <Champ label="Années"><input type="number" min="2" value={jeton.annees} onChange={(e) => setJeton({ ...jeton, annees: e.target.value })} className={INPUT} /></Champ>}
|
||||
<button onClick={enregistrerJeton} disabled={loading} className="w-full p-3 bg-aa-gold text-white rounded-xl text-sm font-bold disabled:opacity-40">Comptabiliser</button>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{action === 'Modifier' && (
|
||||
<Card titre="Modifier la rencontre">
|
||||
<Champ label="Animé par"><MembreSelect value={rapport.animateur_id} onChange={(v) => setRapport({ ...rapport, animateur_id: v })} membres={membres} /></Champ>
|
||||
<Champ label="Sujet"><input value={rapport.theme} onChange={(e) => setRapport({ ...rapport, theme: e.target.value })} className={INPUT} /></Champ>
|
||||
<Champ label="Annonces"><textarea value={rapport.annonces} onChange={(e) => setRapport({ ...rapport, annonces: e.target.value })} rows={3} className={`${INPUT} resize-none`} /></Champ>
|
||||
<Champ label="Note"><textarea value={rapport.note} onChange={(e) => setRapport({ ...rapport, note: e.target.value })} rows={2} className={`${INPUT} resize-none`} /></Champ>
|
||||
<button onClick={enregistrerRapport} disabled={loading} className="w-full p-3 bg-green-600 text-white rounded-xl text-sm font-bold disabled:opacity-40">Enregistrer</button>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Resume({ rencontre }) {
|
||||
return (
|
||||
<div className="text-sm text-gray-600 space-y-1">
|
||||
<p>Animateur : {rencontre.animateur_prenom || '—'}</p>
|
||||
{rencontre.collecte && <p>Collecte : {rencontre.collecte.montant.toFixed(2)} $ ({rencontre.collecte.statut}) détenue par {rencontre.collecte.detenteur_prenom || '—'}</p>}
|
||||
{rencontre.ventes_litterature.map((v) => <p key={v.id}>Vente littérature : {v.titre} x{v.quantite}, {v.montant.toFixed(2)} $ par {v.vendu_par_prenom || '—'}</p>)}
|
||||
{rencontre.ventes_jetons.map((v) => <p key={v.id}>Vente jeton : {v.type_jeton} x{v.quantite}, {v.montant.toFixed(2)} $ par {v.vendu_par_prenom || '—'}</p>)}
|
||||
{rencontre.jetons.map((j) => <p key={j.id}>Jeton/gâteau : {j.membre_prenom || 'Anonyme'} — {j.type_jeton}{j.annees ? ` (${j.annees} ans)` : ''}</p>)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Card({ titre, children }) {
|
||||
return <div className="bg-white rounded-2xl p-4 shadow-sm space-y-3"><h2 className="font-bold text-gray-800">{titre}</h2>{children}</div>;
|
||||
}
|
||||
|
|
@ -199,13 +377,11 @@ function Champ({ label, children }) {
|
|||
return <label className="block"><span className="text-xs text-gray-400 block mb-1">{label}</span>{children}</label>;
|
||||
}
|
||||
|
||||
function SelectDate({ value, onChange, dates }) {
|
||||
if (!dates.length) {
|
||||
return <div className="rounded-xl bg-gray-50 p-3 text-sm text-gray-500">Configurez le jour de réunion dans les paramètres du groupe.</div>;
|
||||
}
|
||||
return (
|
||||
<select value={value} onChange={(e) => onChange(e.target.value)} className={INPUT}>
|
||||
{dates.map((d) => <option key={d} value={d}>{labelDateReunion(d)}</option>)}
|
||||
</select>
|
||||
);
|
||||
function MembreSelect({ value, onChange, membres, empty = 'Sélectionner' }) {
|
||||
return <select value={value} onChange={(e) => onChange(e.target.value)} className={INPUT}><option value="">{empty}</option>{membres.map((m) => <option key={m.id} value={m.id}>{m.prenom}</option>)}</select>;
|
||||
}
|
||||
|
||||
function SelectDate({ value, onChange, dates }) {
|
||||
if (!dates.length) return <div className="rounded-xl bg-gray-50 p-3 text-sm text-gray-500">Configurez le jour de réunion.</div>;
|
||||
return <select value={value} onChange={(e) => onChange(e.target.value)} className={INPUT}>{dates.map((d) => <option key={d} value={d}>{labelDateReunion(d)}</option>)}</select>;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue