Ameliorer gouvernance tresorerie et seed initial

This commit is contained in:
Daniel Allaire 2026-05-26 08:15:28 -04:00
parent a936e7ed81
commit 56b69b1bb8
26 changed files with 979 additions and 228 deletions

View file

@ -6,6 +6,7 @@ groupe_meditation_domain: groupe-meditation.87-16.org
groupe_meditation_app_dir: /opt/groupe-meditation
groupe_meditation_backend_dir: "{{ groupe_meditation_app_dir }}/backend"
groupe_meditation_deploy_dir: "{{ groupe_meditation_app_dir }}/deploy"
groupe_meditation_data_dir: "{{ groupe_meditation_app_dir }}/data"
groupe_meditation_frontend_dir: "{{ groupe_meditation_app_dir }}/frontend"
groupe_meditation_web_root: /var/www/groupe-meditation
groupe_meditation_upload_dir: "{{ groupe_meditation_app_dir }}/uploads/preuves"

View file

@ -26,6 +26,7 @@
group: root
- path: "{{ groupe_meditation_upload_dir }}"
mode: "0755"
- path: "{{ groupe_meditation_data_dir }}/import"
- path: "{{ groupe_meditation_web_root }}"
- name: Synchroniser le backend
@ -47,6 +48,13 @@
delete: true
notify: Redemarrer groupe-meditation
- name: Synchroniser les donnees d'import
ansible.posix.synchronize:
src: "{{ playbook_dir }}/../data/"
dest: "{{ groupe_meditation_data_dir }}/"
delete: true
notify: Redemarrer groupe-meditation
- name: Synchroniser le frontend
ansible.posix.synchronize:
src: "{{ playbook_dir }}/../frontend/"
@ -163,14 +171,17 @@
ansible.builtin.shell:
cmd: |
set -euo pipefail
printf '%s\n%s\n%s\n%s\n%s\n%s\n' \
{{ groupe_meditation_first_member.prenom | quote }} \
{{ groupe_meditation_first_member.nom | default("") | quote }} \
{{ groupe_meditation_first_member.courriel | quote }} \
{{ groupe_meditation_first_member.telephone | quote }} \
{{ groupe_meditation_first_member.pin | quote }} \
{{ groupe_meditation_first_member.poste_numero | default("1") | quote }} \
| {{ groupe_meditation_backend_dir }}/venv/bin/python ../deploy/seed.py
{
printf '%s\n%s\n%s\n%s\n%s\n' \
{{ groupe_meditation_first_member.prenom | quote }} \
{{ groupe_meditation_first_member.nom | default("") | quote }} \
{{ groupe_meditation_first_member.courriel | quote }} \
{{ groupe_meditation_first_member.telephone | quote }} \
{{ groupe_meditation_first_member.pin | quote }}
{% if groupe_meditation_first_member.courriel | lower != "sysadmin" %}
printf '%s\n' {{ groupe_meditation_first_member.poste_numero | default("1") | quote }}
{% endif %}
} | {{ groupe_meditation_backend_dir }}/venv/bin/python ../deploy/seed.py
executable: /bin/bash
chdir: "{{ groupe_meditation_backend_dir }}"
when:

View file

@ -25,6 +25,7 @@ from app.models.transaction_comptable import TransactionComptable # noqa: F401
from app.models.mouvement_reserve import MouvementReserve # noqa: F401
from app.models.journal_action import JournalAction # noqa: F401
from app.models.push_subscription import PushSubscription # noqa: F401
from app.models.destinataire_contribution import DestinataireContribution # noqa: F401
@asynccontextmanager
@ -38,10 +39,23 @@ async def lifespan(app: FastAPI):
await conn.execute(text("ALTER TABLE propositions ADD COLUMN IF NOT EXISTS date_adoption DATE"))
await conn.execute(text("ALTER TABLE propositions ADD COLUMN IF NOT EXISTS etant_donne_que TEXT"))
await conn.execute(text("ALTER TABLE propositions ADD COLUMN IF NOT EXISTS groupe_a_decide_de TEXT"))
await conn.execute(text(
"ALTER TABLE envois_contributions "
"ALTER COLUMN destination TYPE VARCHAR(120) USING destination::text"
))
await conn.execute(text("DROP TABLE IF EXISTS votes"))
await conn.execute(text("DROP TABLE IF EXISTS options_sondage"))
await conn.execute(text("DROP TABLE IF EXISTS sondages"))
await conn.execute(text("DROP TABLE IF EXISTS decisions"))
await conn.execute(text(
"INSERT INTO destinataires_contributions (id, groupe_id, nom, protege, actif, cree_le) "
"SELECT CAST(md5(random()::text || clock_timestamp()::text) AS uuid), g.id, 'District 87-16', true, true, now() "
"FROM groupes g "
"WHERE NOT EXISTS ("
" SELECT 1 FROM destinataires_contributions d "
" WHERE d.groupe_id = g.id AND d.nom = 'District 87-16'"
")"
))
yield
await engine.dispose()

View file

@ -0,0 +1,20 @@
"""Destinataires configurables pour les contributions."""
import uuid
from datetime import datetime, timezone
from sqlalchemy import Boolean, DateTime, ForeignKey, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
class DestinataireContribution(Base):
__tablename__ = "destinataires_contributions"
__table_args__ = (
UniqueConstraint("groupe_id", "nom", name="uq_destinataire_contribution_groupe_nom"),
)
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)
nom: Mapped[str] = mapped_column(String(120), nullable=False)
protege: Mapped[bool] = mapped_column(Boolean, default=False)
actif: Mapped[bool] = mapped_column(Boolean, default=True)
cree_le: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))

View file

@ -1,7 +1,7 @@
"""Table envois_contributions — Envois aux organismes AA."""
import uuid
from datetime import date, datetime, timezone
from sqlalchemy import Boolean, Date, DateTime, ForeignKey, Numeric, Enum as SAEnum
from sqlalchemy import Boolean, Date, DateTime, ForeignKey, Numeric, Enum as SAEnum, String
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
@ -12,10 +12,7 @@ class EnvoiContribution(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)
date_envoi: Mapped[date] = mapped_column(Date, nullable=False)
destination: Mapped[str] = mapped_column(
SAEnum("district", "region", "bsg", "intergroupe", name="destination_contribution"),
nullable=False,
)
destination: Mapped[str] = mapped_column(String(120), nullable=False)
montant: Mapped[float] = mapped_column(Numeric(10, 2), nullable=False)
methode: Mapped[str] = mapped_column(
SAEnum("DU", "VIREMENT", "CHEQUE", name="methode_contribution"),

View file

@ -36,7 +36,7 @@ MODULES_SAISIE_TOUS = [
# Modules de saisie par poste
MODULES_SAISIE = {
"Trésorier(ière)": [
ModuleTuile(code="M4", nom="Envoi contributions", description="District, région, BSG", icone="📮", couleur="blue"),
ModuleTuile(code="M4", nom="Donner au suivant", description="Destinataires et contributions", icone="📮", couleur="blue"),
],
"Secrétaire": [
ModuleTuile(code="M1", nom="Rédiger PV", description="Procès-verbal", icone="📝", couleur="blue"),

View file

@ -2,7 +2,7 @@
from datetime import date
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc
from sqlalchemy import select, desc, func
from app.core.database import get_db
from app.core.decisions import prochain_numero_decision
@ -12,14 +12,16 @@ from app.core.security import get_current_membre
from app.models.envoi_contribution import EnvoiContribution
from app.models.config_repartition import ConfigRepartition
from app.models.membre import Membre
from app.models.destinataire_contribution import DestinataireContribution
from app.services.finance import solde_banque, total_reserves, transaction
from app.schemas.phase7 import (
EnvoiContributionCreate, EnvoiContributionOut,
CalculRepartitionIn, CalculRepartitionOut,
DESTINATIONS, DESTINATIONS_LABELS, METHODES_CONTRIBUTION,
DestinataireContributionCreate, DestinataireContributionUpdate, DestinataireContributionOut,
)
router = APIRouter(prefix="/contributions", tags=["M4 — Envoi contributions"])
router = APIRouter(prefix="/contributions", tags=["M4 — Donner au suivant"])
# Répartition par défaut selon le Guide du groupe
DEFAUT_REPARTITION = {
@ -53,6 +55,121 @@ async def _get_repartition(groupe_id, db: AsyncSession) -> dict[str, float]:
return repartition
async def _ensure_destinataire_defaut(groupe_id, db: AsyncSession) -> DestinataireContribution:
result = await db.execute(
select(DestinataireContribution).where(
DestinataireContribution.groupe_id == groupe_id,
func.lower(DestinataireContribution.nom) == "district 87-16",
)
)
dest = result.scalar_one_or_none()
if dest:
if not dest.protege or not dest.actif:
dest.protege = True
dest.actif = True
await db.flush()
return dest
dest = DestinataireContribution(groupe_id=groupe_id, nom="District 87-16", protege=True, actif=True)
db.add(dest)
await db.flush()
return dest
async def _get_destinataire(groupe_id, nom: str, db: AsyncSession) -> DestinataireContribution:
nom = (nom or "").strip()
if not nom:
raise HTTPException(status_code=400, detail="Destinataire requis")
await _ensure_destinataire_defaut(groupe_id, db)
result = await db.execute(
select(DestinataireContribution).where(
DestinataireContribution.groupe_id == groupe_id,
func.lower(DestinataireContribution.nom) == nom.lower(),
DestinataireContribution.actif == True,
)
)
dest = result.scalar_one_or_none()
if not dest:
raise HTTPException(status_code=404, detail="Destinataire introuvable")
return dest
@router.get("/destinataires", response_model=list[DestinataireContributionOut])
async def liste_destinataires(
membre=Depends(get_current_membre),
db: AsyncSession = Depends(get_db),
):
await _ensure_destinataire_defaut(membre.groupe_id, db)
result = await db.execute(
select(DestinataireContribution).where(
DestinataireContribution.groupe_id == membre.groupe_id,
DestinataireContribution.actif == True,
).order_by(DestinataireContribution.protege.desc(), DestinataireContribution.nom)
)
return [DestinataireContributionOut.model_validate(d) for d in result.scalars().all()]
@router.post("/destinataires", response_model=DestinataireContributionOut)
async def creer_destinataire(
req: DestinataireContributionCreate,
membre=Depends(require_tresorier),
db: AsyncSession = Depends(get_db),
):
nom = req.nom.strip()
if not nom:
raise HTTPException(status_code=400, detail="Nom requis")
result = await db.execute(
select(DestinataireContribution).where(
DestinataireContribution.groupe_id == membre.groupe_id,
func.lower(DestinataireContribution.nom) == nom.lower(),
)
)
existant = result.scalar_one_or_none()
if existant:
existant.actif = True
await db.flush()
return DestinataireContributionOut.model_validate(existant)
dest = DestinataireContribution(groupe_id=membre.groupe_id, nom=nom, protege=nom.lower() == "district 87-16")
db.add(dest)
await db.flush()
return DestinataireContributionOut.model_validate(dest)
@router.patch("/destinataires/{destinataire_id}", response_model=DestinataireContributionOut)
async def modifier_destinataire(
destinataire_id: str,
req: DestinataireContributionUpdate,
membre=Depends(require_tresorier),
db: AsyncSession = Depends(get_db),
):
dest = await db.get(DestinataireContribution, destinataire_id)
if not dest or dest.groupe_id != membre.groupe_id or not dest.actif:
raise HTTPException(status_code=404, detail="Destinataire introuvable")
if dest.protege:
raise HTTPException(status_code=400, detail="Ce destinataire est protégé")
nom = req.nom.strip()
if not nom:
raise HTTPException(status_code=400, detail="Nom requis")
dest.nom = nom
await db.flush()
return DestinataireContributionOut.model_validate(dest)
@router.delete("/destinataires/{destinataire_id}")
async def supprimer_destinataire(
destinataire_id: str,
membre=Depends(require_tresorier),
db: AsyncSession = Depends(get_db),
):
dest = await db.get(DestinataireContribution, destinataire_id)
if not dest or dest.groupe_id != membre.groupe_id or not dest.actif:
raise HTTPException(status_code=404, detail="Destinataire introuvable")
if dest.protege:
raise HTTPException(status_code=400, detail="Ce destinataire est protégé")
dest.actif = False
await db.flush()
return {"message": "Destinataire supprimé"}
@router.post("/calculer", response_model=list[CalculRepartitionOut])
async def calculer_repartition(
req: CalculRepartitionIn,
@ -84,8 +201,7 @@ async def enregistrer_envoi(
"""M4 — Enregistrer un envoi de contribution et créer sa résolution."""
from app.models.proposition import Proposition
if req.destination not in DESTINATIONS:
raise HTTPException(status_code=400, detail=f"Destination invalide. Choix: {DESTINATIONS}")
destination = await _get_destinataire(membre.groupe_id, req.destination, db)
if req.methode.upper() not in METHODES_CONTRIBUTION:
raise HTTPException(status_code=400, detail=f"Méthode invalide. Choix: {METHODES_CONTRIBUTION}")
if req.proposeur_id == req.secondeur_id:
@ -103,9 +219,9 @@ async def enregistrer_envoi(
resolution = Proposition(
groupe_id=membre.groupe_id,
type_proposition="contribution",
sujet=f"Envoi de {req.montant:.2f} $ à {DESTINATIONS_LABELS[req.destination]}",
sujet=f"Envoi de {req.montant:.2f} $ à {destination.nom}",
description=(
f"Contribution enregistrée le {req.date_envoi} par le module Envoi contributions. "
f"Contribution enregistrée le {req.date_envoi} par le module Donner au suivant. "
f"Méthode prévue : {req.methode.upper()}."
),
proposeur_id=req.proposeur_id,
@ -114,7 +230,7 @@ async def enregistrer_envoi(
numero_resolution=numero_resolution,
date_vote=date_decision,
date_adoption=date_decision,
groupe_a_decide_de=f"Envoyer {req.montant:.2f} $ à {DESTINATIONS_LABELS[req.destination]}.",
groupe_a_decide_de=f"Envoyer {req.montant:.2f} $ à {destination.nom}.",
)
db.add(resolution)
await db.flush()
@ -122,7 +238,7 @@ async def enregistrer_envoi(
envoi = EnvoiContribution(
groupe_id=membre.groupe_id,
date_envoi=req.date_envoi,
destination=req.destination,
destination=destination.nom,
montant=req.montant,
methode=req.methode.upper(),
resolution_id=resolution.id,
@ -137,7 +253,7 @@ async def enregistrer_envoi(
id=envoi.id,
date_envoi=envoi.date_envoi,
destination=envoi.destination,
destination_label=DESTINATIONS_LABELS[envoi.destination],
destination_label=DESTINATIONS_LABELS.get(envoi.destination, envoi.destination),
montant=float(envoi.montant),
methode=envoi.methode,
resolution_id=envoi.resolution_id,

View file

@ -4,7 +4,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from app.core.database import get_db
from app.core.security import get_current_membre, require_executif, hash_pin, verify_pin
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
@ -116,6 +116,8 @@ async def desactiver_membre(
membre = result.scalar_one_or_none()
if not membre:
raise HTTPException(status_code=404, detail="Membre introuvable")
if is_sysadmin(membre):
raise HTTPException(status_code=400, detail="Le compte Sysadmin est protégé")
membre.actif = False
await db.flush()
return {"message": f"{membre.prenom} a été désactivé"}
@ -136,6 +138,8 @@ async def droit_oubli(
raise HTTPException(status_code=404, detail="Membre introuvable")
if str(membre.id) == str(executif.id):
raise HTTPException(status_code=400, detail="Impossible de supprimer votre propre compte")
if is_sysadmin(membre):
raise HTTPException(status_code=400, detail="Le compte Sysadmin est protégé")
prenom_original = membre.prenom
# Anonymiser

View file

@ -12,7 +12,7 @@ from app.models.affectation import Affectation
from app.models.membre import Membre
from app.models.candidature import Candidature
from app.models.poste_module import PosteModule
from app.schemas.schemas import PosteOut, PosteCreate, AffectationCreate, TerminerMandatRequest
from app.schemas.schemas import PosteOut, PosteCreate, PosteUpdate, AffectationCreate, TerminerMandatRequest
router = APIRouter(prefix="/postes", tags=["Postes et affectations"])
@ -56,6 +56,28 @@ async def creer_poste(
return PosteOut.model_validate(poste)
@router.patch("/{poste_id}", response_model=PosteOut)
async def modifier_poste(
poste_id: str,
req: PosteUpdate,
executif=Depends(require_executif),
db: AsyncSession = Depends(get_db),
):
poste = await db.get(Poste, poste_id)
if not poste:
raise HTTPException(status_code=404, detail="Poste introuvable")
if req.categorie is not None and req.categorie not in ("executif", "service", "physique"):
raise HTTPException(status_code=400, detail="Catégorie invalide")
for attr in ["nom", "categorie", "description", "prerequis", "informations", "ordre_affichage"]:
value = getattr(req, attr)
if value is not None:
setattr(poste, attr, value.strip() if isinstance(value, str) else value)
if not poste.nom:
raise HTTPException(status_code=400, detail="Nom requis")
await db.flush()
return PosteOut.model_validate(poste)
@router.delete("/{poste_id}")
async def abolir_poste(
poste_id: str,
@ -139,11 +161,34 @@ async def postes_disponibles(
"categorie": p.categorie,
"description": p.description,
"prerequis": p.prerequis,
"informations": p.informations,
"deja_postule": deja_postule,
"candidats": await _candidats_poste(p.id, db),
})
return disponibles
async def _candidats_poste(poste_id, db: AsyncSession):
result = await db.execute(
select(Candidature).where(
Candidature.poste_id == poste_id,
Candidature.statut == "proposee",
)
)
candidats = []
for c in result.scalars().all():
membre = await db.get(Membre, c.membre_id)
if membre:
candidats.append({
"id": str(c.id),
"membre_id": str(membre.id),
"prenom": membre.prenom,
"type": c.type,
"motivation": c.motivation,
})
return candidats
# ── Candidatures ──
@router.post("/candidatures")
@ -235,6 +280,47 @@ async def nominer(
}
@router.post("/candidatures/proposer")
async def proposer_membre(
poste_id: str,
membre_id: str,
motivation: str = "",
membre=Depends(get_current_membre),
db: AsyncSession = Depends(get_db),
):
"""Proposer un autre membre pour un poste."""
poste = await db.get(Poste, poste_id)
if not poste or not poste.actif:
raise HTTPException(status_code=404, detail="Poste introuvable ou aboli")
cible = await db.get(Membre, membre_id)
if not cible or cible.groupe_id != membre.groupe_id or not cible.actif:
raise HTTPException(status_code=404, detail="Membre introuvable")
if cible.id == membre.id:
raise HTTPException(status_code=400, detail="Utilisez Postuler pour votre propre candidature")
result = await db.execute(
select(Candidature).where(
Candidature.poste_id == poste_id,
Candidature.membre_id == membre_id,
Candidature.statut == "proposee",
)
)
if result.scalars().first():
raise HTTPException(status_code=400, detail="Ce membre est déjà proposé pour ce poste")
cand = Candidature(
groupe_id=membre.groupe_id,
poste_id=poste_id,
membre_id=membre_id,
type="nomination",
nomme_par=membre.id,
motivation=motivation.strip() or None,
)
db.add(cand)
await db.flush()
return {"message": f"{cible.prenom} proposé(e) pour {poste.nom}", "id": str(cand.id)}
@router.post("/candidatures/{candidature_id}/retirer")
async def retirer_candidature(
candidature_id: str,

View file

@ -7,6 +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.security import get_current_membre, require_executif
from app.core.roles import membre_a_poste_ou_executif
from app.models.proposition import Proposition
from app.models.reunion import Reunion
from app.models.membre import Membre
@ -77,6 +78,7 @@ async def creer_proposition(
@router.post("/{proposition_id}/seconder", response_model=PropositionOut)
async def seconder_proposition(
proposition_id: str,
membre_id: str | None = None,
membre=Depends(get_current_membre),
db: AsyncSession = Depends(get_db),
):
@ -92,10 +94,19 @@ async def seconder_proposition(
raise HTTPException(status_code=404, detail="Proposition introuvable")
if prop.statut != "proposee":
raise HTTPException(status_code=400, detail="Seule une proposition en attente peut être secondée")
if prop.proposeur_id == membre.id:
secondeur = membre
if membre_id and str(membre_id) != str(membre.id):
if not await membre_a_poste_ou_executif(membre.id, "executif", db):
raise HTTPException(status_code=403, detail="Réservé aux exécutifs")
secondeur = await db.get(Membre, membre_id)
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:
raise HTTPException(status_code=400, detail="On ne peut pas seconder sa propre proposition")
prop.secondeur_id = membre.id
prop.secondeur_id = secondeur.id
prop.statut = "secondee"
await db.flush()
return await _to_out(prop, db)
@ -163,8 +174,12 @@ async def voter_proposition(
db.add(affectation)
elif req.statut == "rejetee":
if req.note:
prop.note = req.note.strip()
etant_donne_que = (req.etant_donne_que or "").strip()
if not etant_donne_que:
raise HTTPException(status_code=400, detail='La section "ÉTANT DONNÉ QUE" est obligatoire pour rejeter une proposition')
prop.etant_donne_que = etant_donne_que
prop.groupe_a_decide_de = None
prop.note = (req.note or "").strip() or None
# Si nomination rejetée, mettre la candidature en refusée
if prop.type_proposition == "nomination" and prop.candidature_id:
@ -211,10 +226,10 @@ async def registre_resolutions(
membre=Depends(get_current_membre),
db: AsyncSession = Depends(get_db),
):
"""Registre des résolutions adoptées."""
"""Registre des décisions adoptées ou rejetées."""
query = select(Proposition).where(
Proposition.groupe_id == membre.groupe_id,
Proposition.statut == "adoptee",
Proposition.statut.in_(["adoptee", "rejetee"]),
)
if annee:
query = query.where(extract("year", Proposition.cree_le) == annee)

View file

@ -5,8 +5,9 @@ from uuid import UUID
from typing import Optional
DESTINATIONS = ["district", "region", "bsg", "intergroupe"]
DESTINATIONS = ["District 87-16"]
DESTINATIONS_LABELS = {
"District 87-16": "District 87-16",
"district": "District 87-16",
"region": "Région 87",
"bsg": "BSG",
@ -18,7 +19,7 @@ METHODES_CONTRIBUTION = ["DU", "VIREMENT", "CHEQUE"]
# ── M4 Envoi de contributions ──
class EnvoiContributionCreate(BaseModel):
date_envoi: date
destination: str # district, region, bsg, intergroupe
destination: str
montant: float
methode: str # DU, VIREMENT, CHEQUE
resolution_id: Optional[UUID] = None
@ -54,6 +55,23 @@ class CalculRepartitionOut(BaseModel):
montant: float
class DestinataireContributionCreate(BaseModel):
nom: str
class DestinataireContributionUpdate(BaseModel):
nom: str
class DestinataireContributionOut(BaseModel):
id: UUID
nom: str
protege: bool = False
actif: bool = True
model_config = {"from_attributes": True}
# ── G8 Config répartition ──
class ConfigRepartitionItem(BaseModel):
destination: str

View file

@ -73,6 +73,15 @@ class PosteCreate(BaseModel):
ordre_affichage: Optional[int] = None
class PosteUpdate(BaseModel):
nom: Optional[str] = None
categorie: Optional[str] = None
description: Optional[str] = None
prerequis: Optional[str] = None
informations: Optional[str] = None
ordre_affichage: Optional[int] = None
class PosteOut(BaseModel):
id: UUID
nom: str

View file

@ -0,0 +1,166 @@
code_bsg,titre,categorie,prix_coutant
M-73,Histoire du Gros Livre en Bref (Trilingue),livre,2.00
FB-1,Les Alcooliques anonymes (relié),livre,25.00
FB-30,Les Alcooliques anonymes (souple),livre,23.50
FB-35,Les Alcooliques anonymes (poche),livre,15.00
FB-16,Les Alcooliques anonymes (gros caractères),livre,25.00
FB-24,Les Alcooliques anonymes (gros caractères abrégé),livre,15.00
FB-2,Les 12 étapes et les 12 traditions (relié),livre,23.50
FB-15,Les 12 étapes et les 12 traditions (souple),livre,21.50
FB-17,Les 12 étapes et les 12 traditions (poche),livre,18.50
FB-14,Les 12 étapes et les 12 traditions (gros caractères),livre,23.50
FB-3,Le mouvement des AA devient adulte,livre,25.20
FB-6,Nous en sommes venus à croire,livre,15.10
FB-7,Vivre... sans alcool,livre,15.10
FB-8,Dr Bob et les pionniers,livre,25.20
FB-9,Transmets-le,livre,25.20
FB-12,Réflexions quotidiennes,livre,26.00
FB-13,A.A. en prison,livre,9.30
FB-18,Réflexions de Bill,livre,23.50
FB-20,"Expérience, Force et Espoir",livre,15.10
FB-28,Les AA pour l'alcoolique plus âgé,livre,4.30
FB-70,Notre grande responsabilité,livre,25.20
FB-86,Un livre de l'amitié - livre souvenir 2025,livre,30.00
FBM-31,Le manuel du service & 12 concepts,livre,13.40
L-10,Le langage du cœur,livre,28.50
L-11,Le groupe d'attache: Battement du coeur,livre,13.80
FBB-06,Les meilleurs articles de Bill,livre,13.40
FGV-17,La sobriété émotive,livre,25.20
FGV-29,"Heureux, joyeux et libres, une sobriété enjouée",livre,25.20
FGV-30,En Tête à tête - Le parrainage AA en action,livre,25.20
FGV-32,Citation du jour - Grapevine,livre,25.20
D-60,L'ivresse mentale,livre,5.00
D-80,Album souvenir 1944-2024 - 80 ans des AA au Québec,livre,10.00
D-100,Un cœur rempli de gratitude,livre,15.00
B-0,Alcoholics Anonymous reprod. 1939 (anglais seul.),livre,3.50
FP-1,Voici les AA,brochure,0.70
FP-56,Voici les AA (Gros caractères),brochure,1.90
FP-24,Un nouveau veut savoir,brochure,0.40
FP-3,Les AA sont-ils pour vous?,brochure,0.40
FP-35,Problèmes autres que l'alcoolisme,brochure,0.70
FP-30,Y a-t-il un alcoolique dans votre vie?,brochure,0.70
FP-4,Les jeunes et les AA,brochure,1.10
FP-5,Les femmes des AA,brochure,0.90
FP-13,Vous croyez-vous différent?,brochure,0.90
FP-11,Le membre AA face aux méd. et drogue,brochure,0.90
FP-9,Derrière les murs : un message d’espoir,brochure,1.30
FP-33,Ça vaut mieux que de poireauter en prison,brochure,4.30
FP-12,L'article de Jack Alexander sur les AA,brochure,1.30
FP-6,3 Causeries à des sociétés médicales,brochure,1.00
FP-2,Foire aux questions sur les AA,brochure,0.80
FP-57,Foire aux questions sur les AA (Gros caractères),brochure,2.40
FP-15,Questions et réponses sur le parrainage,brochure,0.70
FP-38,Jean… face à son problème d'alcool,brochure,1.00
FP-39,L'histoire de Nicole,brochure,1.00
FP-36,Les AA sont-ils pour moi?,brochure,0.90
FP-51,Les Noirs chez les AA,brochure,1.60
FP-55,Les douze étapes illustrées,brochure,0.90
FP-21,Les peuples autochtones chez les AA,brochure,3.20
FP-32,Les alcooliques LGBTQ des AA,brochure,1.00
FP-10,Notre méthode,brochure,0.50
FP-83,L’accès aux AA,brochure,1.30
FP-84,Différentes avenues vers la spiritualité,brochure,0.80
FP-50,Les AA et les Forces armées,brochure,1.50
FP-86,Le Mot Dieu : athées et agnostiques,brochure,1.00
FP-87,Les AA pour les alcooliques atteints de maladie mentale,brochure,1.20
FP-16,Le groupe des AA,brochure,1.10
FP-17,La tradition des AA et son développement,brochure,1.90
FP-18,La structure de l'association des AA,brochure,1.30
FP-19,Le RSG,brochure,0.50
FP-43,Les douze traditions illustrées,brochure,1.10
FP-8,Les douze concepts illustrés,brochure,1.50
FP-44,L'héritage des services des AA,brochure,1.10
FP-45,Cercles d'amour et de service,brochure,0.50
FP-53,Les deux fondateurs d'AA,brochure,1.30
FP-91,L'expérience nous a appris : introduction à nos 12 traditions,brochure,1.50
FP-20,Message aux directeurs d'établissements correctionnels,brochure,0.90
FP-23,Les AA: ressource pour professionnels de la santé,brochure,0.90
FP-25,Les membres du clergé se renseignent...,brochure,0.90
FP-26,Les AA dans les centres de détention,brochure,1.50
FP-27,Les AA dans les centres de traitement,brochure,1.10
FP-46,Vous vous occupez professionnellement d'alcool.,brochure,1.50
FP-49,Favoriser le rapprochement,brochure,0.70
FP-54,Y a-t-il un alcoolique dans votre milieu de travail,brochure,0.50
FP-29,Collaboration des membres des AA,brochure,1.30
FP-31,Les AA dans votre milieu,brochure,0.90
FP-34,Collaborons avec nos amis,brochure,0.90
FP-40,Causeries à l'extérieur des AA,brochure,1.50
FP-41,Point de vue d'un membre sur les AA,brochure,1.10
FP-42,Petit guide pratique sur les AA,brochure,0.90
FP-47,Le sens de l'anonymat,brochure,0.70
FP-48,Sondage sur les membres AA,brochure,0.50
FP-10P,"Notre méthode, plastifiée",depliant,1.50
D-02,Carte 12 étapes et 12 traditions (8 1/2 X 11),depliant,4.00
D-03,Carte 12 Concepts plastifiée (8 1/2 X 11),depliant,1.25
D-05,Les promesses de la 9e étape (plastifié),depliant,1.25
D-06,Affiche AA avec flèches (pour l'intérieur),depliant,1.00
D-10,Prière de la sérénité (12 x 16),depliant,3.00
D-12,Slogans (jeu de 5),depliant,11.10
D-13R,Déclaration de Responsabilité,depliant,3.00
D-13U,Déclaration d'Unité,depliant,3.00
D-14,Prière de la sérénité (23X35),depliant,10.00
D-25S,Pochette de bienvenue (sans liste de réunions),depliant,3.00
D-25J,Pochette de bienvenue avec jeton plastique,depliant,3.75
FM-17,Dernier message de Bill W. & Bob S.,depliant,9.50
FM-61,Affichette sur l'anonymat,depliant,0.50
FM-77,Anonymat à l'ère numérique (11x17) carton mousse,depliant,22.40
FM-78,"Anonymat à l'ère numérique (8,5x11) carton mousse",depliant,17.30
FMG-20,Lignes de conduites des AA (série),depliant,5.20
M-30,Affiche réunion AA,depliant,14.30
FT-01,Affiches noires AA (jeu de 3),depliant,3.45
D-30,Livret - Lectures de réunions en gros caractères,depliant,2.00
A-03,Épinglette AA 87,pamphlet,5.00
A-08,Carte de souhait ou d'anniversaire sobriété,pamphlet,5.00
A-08M,Carte de souhait « Merci »,pamphlet,7.00
A-09,Signet,pamphlet,2.50
A-10,Médaillon Prière de la Sérénité étain antique,pamphlet,17.00
A-11,Médaillon avec chaîne (fine ou large),pamphlet,23.00
A-12,Porte-clé Bill et Bob,pamphlet,8.00
A-16,"Mini-signet, fait main, non plastifié",pamphlet,1.00
D-08P,Prière de la sérénité (2 1/2 x 3 1/2),depliant,0.30
D-08M,Prière de la sérénité (2 1/2 x 3 1/2) Aimantée,depliant,2.00
D-09,Prière de la sérénité (5 x 7),depliant,1.50
D-12M,Slogans (jeu de 5) 2x2 magnétiques,depliant,5.00
D-24,Affichette sur l'humilité (8 1/2 X 11),depliant,3.50
D-26,Trousse Sérénité,pamphlet,5.00
D-27,Affichette sur l'acceptation (8 1/2 X 11),depliant,3.50
FM-2,Carte 12 étapes et 12 traditions (3 1/2 X 4 1/2),depliant,0.40
FM-21,Carte Je suis responsable,depliant,0.40
FM-22,Carte sur l'anonymat (3 1/2 x 4 1/2),depliant,0.40
MS-03,Affiche L'homme dans le lit,depliant,8.30
MS-08,Calendrier Grapevine (trilingue),pamphlet,19.30
MS-09,Agenda de poche Grapevine (trilingue),pamphlet,11.10
X-01,"Prière sérénité (collant) (rouge, bleu, vert, noir)",pamphlet,1.25
AS-01,Stylo encre noire - Avec slogan (choix de 4 diff.),pamphlet,3.00
D-26P,Carte Promesse (4.5 x 5.5),depliant,0.50
D-26I,Carte Inventaire quotidien (4.5 x 5.5),depliant,0.50
D-26M,Carte Méditation (4.5 x 5.5),depliant,0.50
D-26A,Carte Aujourd'hui seulement (4.5 x 5.5),depliant,0.50
FM81-A,Les Alcooliques anonymes - version abrégée (CD),livre,12.00
FM-81,Les Alcooliques anonymes - version intégrale (CD),livre,24.00
FM-83,Douze et Douze sur CD,livre,22.00
FCD-23,Le langage du cœur CD,livre,19.30
MD-1,Annuaire international,pamphlet,2.60
A-01,Listes de réunions - Montréal et environs,pamphlet,2.00
A-90,Répertoire de réunions - Région Nord-ouest du Qc,pamphlet,1.85
A-13,Livret AA Une introduction,brochure,0.75
FMS-15,Facsimile Bill & Bob,pamphlet,13.80
D-20,La Vigne AA,pamphlet,4.50
D-42,Sac réutilisable Un jour à la fois,pamphlet,2.00
RSMF-1FR,Carte représentant des publications,depliant,0.00
FF-1,On vous oblige à assister à des réunions des AA ?,depliant,0.00
FF-2,Aperçu sur les AA,depliant,0.00
FF-3,Renseignements sur les AA,depliant,0.00
FF-4,L'autonomie financière…alliance de l'argent et de la spiritualité,depliant,0.00
FF-5,Et maintenant…que vais-je faire?,depliant,0.00
FF-6,Transmettre le message dans les centres correctionnels,depliant,0.00
FF-8,Votre BSG,depliant,0.00
FF-9,Problèmes autres que l'alcoolisme (citations),depliant,0.00
FF-12,Un message au moins de vingt ans,depliant,0.00
FF-17,Votre RDR,depliant,0.00
FF-42,Cartes de réunions ouvertes et fermées,depliant,0.00
FF-47,Carte autonomie financière,depliant,0.00
FF-96,Les Archives des AA,depliant,0.00
FF-107,Message à un trésorier de groupe des AA,depliant,0.00
FF-211,Au service de tous les alcooliques,depliant,0.00
SMF-209,La sécurité chez les AA: notre bien-être commun,depliant,0.00
1 code_bsg titre categorie prix_coutant
2 M-73 Histoire du Gros Livre en Bref (Trilingue) livre 2.00
3 FB-1 Les Alcooliques anonymes (relié) livre 25.00
4 FB-30 Les Alcooliques anonymes (souple) livre 23.50
5 FB-35 Les Alcooliques anonymes (poche) livre 15.00
6 FB-16 Les Alcooliques anonymes (gros caractères) livre 25.00
7 FB-24 Les Alcooliques anonymes (gros caractères abrégé) livre 15.00
8 FB-2 Les 12 étapes et les 12 traditions (relié) livre 23.50
9 FB-15 Les 12 étapes et les 12 traditions (souple) livre 21.50
10 FB-17 Les 12 étapes et les 12 traditions (poche) livre 18.50
11 FB-14 Les 12 étapes et les 12 traditions (gros caractères) livre 23.50
12 FB-3 Le mouvement des AA devient adulte livre 25.20
13 FB-6 Nous en sommes venus à croire livre 15.10
14 FB-7 Vivre... sans alcool livre 15.10
15 FB-8 Dr Bob et les pionniers livre 25.20
16 FB-9 Transmets-le livre 25.20
17 FB-12 Réflexions quotidiennes livre 26.00
18 FB-13 A.A. en prison livre 9.30
19 FB-18 Réflexions de Bill livre 23.50
20 FB-20 Expérience, Force et Espoir livre 15.10
21 FB-28 Les AA pour l'alcoolique plus âgé livre 4.30
22 FB-70 Notre grande responsabilité livre 25.20
23 FB-86 Un livre de l'amitié - livre souvenir 2025 livre 30.00
24 FBM-31 Le manuel du service & 12 concepts livre 13.40
25 L-10 Le langage du cœur livre 28.50
26 L-11 Le groupe d'attache: Battement du coeur livre 13.80
27 FBB-06 Les meilleurs articles de Bill livre 13.40
28 FGV-17 La sobriété émotive livre 25.20
29 FGV-29 Heureux, joyeux et libres, une sobriété enjouée livre 25.20
30 FGV-30 En Tête à tête - Le parrainage AA en action livre 25.20
31 FGV-32 Citation du jour - Grapevine livre 25.20
32 D-60 L'ivresse mentale livre 5.00
33 D-80 Album souvenir 1944-2024 - 80 ans des AA au Québec livre 10.00
34 D-100 Un cœur rempli de gratitude livre 15.00
35 B-0 Alcoholics Anonymous reprod. 1939 (anglais seul.) livre 3.50
36 FP-1 Voici les AA brochure 0.70
37 FP-56 Voici les AA (Gros caractères) brochure 1.90
38 FP-24 Un nouveau veut savoir brochure 0.40
39 FP-3 Les AA sont-ils pour vous? brochure 0.40
40 FP-35 Problèmes autres que l'alcoolisme brochure 0.70
41 FP-30 Y a-t-il un alcoolique dans votre vie? brochure 0.70
42 FP-4 Les jeunes et les AA brochure 1.10
43 FP-5 Les femmes des AA brochure 0.90
44 FP-13 Vous croyez-vous différent? brochure 0.90
45 FP-11 Le membre AA face aux méd. et drogue brochure 0.90
46 FP-9 Derrière les murs : un message d’espoir brochure 1.30
47 FP-33 Ça vaut mieux que de poireauter en prison brochure 4.30
48 FP-12 L'article de Jack Alexander sur les AA brochure 1.30
49 FP-6 3 Causeries à des sociétés médicales brochure 1.00
50 FP-2 Foire aux questions sur les AA brochure 0.80
51 FP-57 Foire aux questions sur les AA (Gros caractères) brochure 2.40
52 FP-15 Questions et réponses sur le parrainage brochure 0.70
53 FP-38 Jean… face à son problème d'alcool brochure 1.00
54 FP-39 L'histoire de Nicole brochure 1.00
55 FP-36 Les AA sont-ils pour moi? brochure 0.90
56 FP-51 Les Noirs chez les AA brochure 1.60
57 FP-55 Les douze étapes illustrées brochure 0.90
58 FP-21 Les peuples autochtones chez les AA brochure 3.20
59 FP-32 Les alcooliques LGBTQ des AA brochure 1.00
60 FP-10 Notre méthode brochure 0.50
61 FP-83 L’accès aux AA brochure 1.30
62 FP-84 Différentes avenues vers la spiritualité brochure 0.80
63 FP-50 Les AA et les Forces armées brochure 1.50
64 FP-86 Le Mot Dieu : athées et agnostiques brochure 1.00
65 FP-87 Les AA pour les alcooliques atteints de maladie mentale brochure 1.20
66 FP-16 Le groupe des AA brochure 1.10
67 FP-17 La tradition des AA et son développement brochure 1.90
68 FP-18 La structure de l'association des AA brochure 1.30
69 FP-19 Le RSG brochure 0.50
70 FP-43 Les douze traditions illustrées brochure 1.10
71 FP-8 Les douze concepts illustrés brochure 1.50
72 FP-44 L'héritage des services des AA brochure 1.10
73 FP-45 Cercles d'amour et de service brochure 0.50
74 FP-53 Les deux fondateurs d'AA brochure 1.30
75 FP-91 L'expérience nous a appris : introduction à nos 12 traditions brochure 1.50
76 FP-20 Message aux directeurs d'établissements correctionnels brochure 0.90
77 FP-23 Les AA: ressource pour professionnels de la santé brochure 0.90
78 FP-25 Les membres du clergé se renseignent... brochure 0.90
79 FP-26 Les AA dans les centres de détention brochure 1.50
80 FP-27 Les AA dans les centres de traitement brochure 1.10
81 FP-46 Vous vous occupez professionnellement d'alcool. brochure 1.50
82 FP-49 Favoriser le rapprochement brochure 0.70
83 FP-54 Y a-t-il un alcoolique dans votre milieu de travail brochure 0.50
84 FP-29 Collaboration des membres des AA brochure 1.30
85 FP-31 Les AA dans votre milieu brochure 0.90
86 FP-34 Collaborons avec nos amis brochure 0.90
87 FP-40 Causeries à l'extérieur des AA brochure 1.50
88 FP-41 Point de vue d'un membre sur les AA brochure 1.10
89 FP-42 Petit guide pratique sur les AA brochure 0.90
90 FP-47 Le sens de l'anonymat brochure 0.70
91 FP-48 Sondage sur les membres AA brochure 0.50
92 FP-10P Notre méthode, plastifiée depliant 1.50
93 D-02 Carte 12 étapes et 12 traditions (8 1/2 X 11) depliant 4.00
94 D-03 Carte 12 Concepts plastifiée (8 1/2 X 11) depliant 1.25
95 D-05 Les promesses de la 9e étape (plastifié) depliant 1.25
96 D-06 Affiche AA avec flèches (pour l'intérieur) depliant 1.00
97 D-10 Prière de la sérénité (12 x 16) depliant 3.00
98 D-12 Slogans (jeu de 5) depliant 11.10
99 D-13R Déclaration de Responsabilité depliant 3.00
100 D-13U Déclaration d'Unité depliant 3.00
101 D-14 Prière de la sérénité (23X35) depliant 10.00
102 D-25S Pochette de bienvenue (sans liste de réunions) depliant 3.00
103 D-25J Pochette de bienvenue avec jeton plastique depliant 3.75
104 FM-17 Dernier message de Bill W. & Bob S. depliant 9.50
105 FM-61 Affichette sur l'anonymat depliant 0.50
106 FM-77 Anonymat à l'ère numérique (11x17) carton mousse depliant 22.40
107 FM-78 Anonymat à l'ère numérique (8,5x11) carton mousse depliant 17.30
108 FMG-20 Lignes de conduites des AA (série) depliant 5.20
109 M-30 Affiche réunion AA depliant 14.30
110 FT-01 Affiches noires AA (jeu de 3) depliant 3.45
111 D-30 Livret - Lectures de réunions en gros caractères depliant 2.00
112 A-03 Épinglette AA 87 pamphlet 5.00
113 A-08 Carte de souhait ou d'anniversaire sobriété pamphlet 5.00
114 A-08M Carte de souhait « Merci » pamphlet 7.00
115 A-09 Signet pamphlet 2.50
116 A-10 Médaillon Prière de la Sérénité étain antique pamphlet 17.00
117 A-11 Médaillon avec chaîne (fine ou large) pamphlet 23.00
118 A-12 Porte-clé Bill et Bob pamphlet 8.00
119 A-16 Mini-signet, fait main, non plastifié pamphlet 1.00
120 D-08P Prière de la sérénité (2 1/2 x 3 1/2) depliant 0.30
121 D-08M Prière de la sérénité (2 1/2 x 3 1/2) Aimantée depliant 2.00
122 D-09 Prière de la sérénité (5 x 7) depliant 1.50
123 D-12M Slogans (jeu de 5) 2x2 magnétiques depliant 5.00
124 D-24 Affichette sur l'humilité (8 1/2 X 11) depliant 3.50
125 D-26 Trousse Sérénité pamphlet 5.00
126 D-27 Affichette sur l'acceptation (8 1/2 X 11) depliant 3.50
127 FM-2 Carte 12 étapes et 12 traditions (3 1/2 X 4 1/2) depliant 0.40
128 FM-21 Carte Je suis responsable depliant 0.40
129 FM-22 Carte sur l'anonymat (3 1/2 x 4 1/2) depliant 0.40
130 MS-03 Affiche L'homme dans le lit depliant 8.30
131 MS-08 Calendrier Grapevine (trilingue) pamphlet 19.30
132 MS-09 Agenda de poche Grapevine (trilingue) pamphlet 11.10
133 X-01 Prière sérénité (collant) (rouge, bleu, vert, noir) pamphlet 1.25
134 AS-01 Stylo encre noire - Avec slogan (choix de 4 diff.) pamphlet 3.00
135 D-26P Carte Promesse (4.5 x 5.5) depliant 0.50
136 D-26I Carte Inventaire quotidien (4.5 x 5.5) depliant 0.50
137 D-26M Carte Méditation (4.5 x 5.5) depliant 0.50
138 D-26A Carte Aujourd'hui seulement (4.5 x 5.5) depliant 0.50
139 FM81-A Les Alcooliques anonymes - version abrégée (CD) livre 12.00
140 FM-81 Les Alcooliques anonymes - version intégrale (CD) livre 24.00
141 FM-83 Douze et Douze sur CD livre 22.00
142 FCD-23 Le langage du cœur CD livre 19.30
143 MD-1 Annuaire international pamphlet 2.60
144 A-01 Listes de réunions - Montréal et environs pamphlet 2.00
145 A-90 Répertoire de réunions - Région Nord-ouest du Qc pamphlet 1.85
146 A-13 Livret AA Une introduction brochure 0.75
147 FMS-15 Facsimile Bill & Bob pamphlet 13.80
148 D-20 La Vigne AA pamphlet 4.50
149 D-42 Sac réutilisable Un jour à la fois pamphlet 2.00
150 RSMF-1FR Carte représentant des publications depliant 0.00
151 FF-1 On vous oblige à assister à des réunions des AA ? depliant 0.00
152 FF-2 Aperçu sur les AA depliant 0.00
153 FF-3 Renseignements sur les AA depliant 0.00
154 FF-4 L'autonomie financière…alliance de l'argent et de la spiritualité depliant 0.00
155 FF-5 Et maintenant…que vais-je faire? depliant 0.00
156 FF-6 Transmettre le message dans les centres correctionnels depliant 0.00
157 FF-8 Votre BSG depliant 0.00
158 FF-9 Problèmes autres que l'alcoolisme (citations) depliant 0.00
159 FF-12 Un message au moins de vingt ans depliant 0.00
160 FF-17 Votre RDR depliant 0.00
161 FF-42 Cartes de réunions ouvertes et fermées depliant 0.00
162 FF-47 Carte autonomie financière depliant 0.00
163 FF-96 Les Archives des AA depliant 0.00
164 FF-107 Message à un trésorier de groupe des AA depliant 0.00
165 FF-211 Au service de tous les alcooliques depliant 0.00
166 SMF-209 La sécurité chez les AA: notre bien-être commun depliant 0.00

Binary file not shown.

View file

@ -8,7 +8,9 @@ import sys
import os
import uuid
import secrets
import csv
from datetime import date, datetime, timedelta, timezone
from pathlib import Path
# Ajouter le backend au path
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "backend"))
@ -42,6 +44,7 @@ from app.models.poste_module import PosteModule
from app.models.reserve import Reserve
from app.models.transaction_comptable import TransactionComptable
from app.models.mouvement_reserve import MouvementReserve
from app.models.destinataire_contribution import DestinataireContribution
POSTES = [
@ -58,6 +61,30 @@ POSTES = [
]
def charger_catalogue_litterature():
chemin = Path(__file__).resolve().parents[1] / "data" / "import" / "Liste-de-prix-rev.-Fevrier-2026.csv"
if not chemin.exists():
return [
{"titre": "Les Alcooliques anonymes (Gros Livre)", "categorie": "livre", "prix_coutant": 12.00, "code_bsg": "B-1"},
{"titre": "Les Douze Étapes et les Douze Traditions", "categorie": "livre", "prix_coutant": 11.00, "code_bsg": "B-2"},
{"titre": "Vivre sans alcool", "categorie": "livre", "prix_coutant": 9.00, "code_bsg": "B-7"},
{"titre": "Les AA sont-ils pour vous?", "categorie": "brochure", "prix_coutant": 0.00, "code_bsg": "P-3"},
{"titre": "Un nouveau arrive", "categorie": "depliant", "prix_coutant": 0.00, "code_bsg": "P-16"},
]
with chemin.open(newline="", encoding="utf-8") as fichier:
return [
{
"code_bsg": row["code_bsg"].strip() or None,
"titre": row["titre"].strip(),
"categorie": row["categorie"].strip(),
"prix_coutant": float(row["prix_coutant"]) if row["prix_coutant"].strip() else None,
}
for row in csv.DictReader(fichier)
if row.get("titre", "").strip()
]
async def seed():
# Créer les tables
async with engine.begin() as conn:
@ -151,23 +178,26 @@ async def seed():
db.add(invitation)
# ── Littérature de base ──
for titre, cat, prix, code_bsg in [
("Les Alcooliques anonymes (Gros Livre)", "livre", 12.00, "B-1"),
("Les Douze Étapes et les Douze Traditions", "livre", 11.00, "B-2"),
("Vivre sans alcool", "livre", 9.00, "B-7"),
("Les AA sont-ils pour vous?", "brochure", 0.00, "P-3"),
("Un nouveau arrive", "depliant", 0.00, "P-16"),
]:
catalogue_litterature = charger_catalogue_litterature()
for item in catalogue_litterature:
db.add(Litterature(
groupe_id=groupe.id,
titre=titre,
categorie=cat,
prix_coutant=prix,
titre=item["titre"],
categorie=item["categorie"],
prix_coutant=item["prix_coutant"],
quantite_stock=0,
seuil_commande=2,
code_bsg=code_bsg,
code_bsg=item["code_bsg"],
))
print(" ✓ Littérature de base : 5 titres")
print(f" ✓ Littérature de base : {len(catalogue_litterature)} titres")
db.add(DestinataireContribution(
groupe_id=groupe.id,
nom="District 87-16",
protege=True,
actif=True,
))
print(" ✓ Destinataire de contribution : District 87-16")
await db.commit()

View file

@ -3,8 +3,8 @@ import { useStore } from '../stores/store';
const NAV_CONTEXTS = [
{ id: 'accueil', label: 'Accueil', icon: '⌂' },
{ id: 'reunion', label: 'Réunion', icon: '□' },
{ id: 'affaires', label: 'Affaires', icon: '◇' },
{ id: 'reunion', label: 'Rencontres', icon: '□' },
{ id: 'affaires', label: 'Assemblées', icon: '◇' },
{ id: 'gestion', label: 'Gestion', icon: '○' },
];
@ -26,7 +26,7 @@ export default function BottomNav() {
key={item.id}
type="button"
onClick={() => ouvrirContexte(item.id)}
className={`min-h-[56px] rounded-xl text-xs font-bold flex flex-col items-center justify-center gap-0.5 transition ${
className={`min-h-[58px] rounded-xl text-[11px] font-bold flex flex-col items-center justify-center gap-0.5 transition ${
actif ? 'bg-aa-blue text-white' : 'text-gray-500 active:bg-gray-100'
}`}
>

View file

@ -4,10 +4,10 @@ import { useStore } from '../stores/store';
export default function PageHeader({ titre, sousTitre }) {
const { setPage } = useStore();
return (
<div className="sticky top-0 z-30 bg-aa-blue text-white px-3 py-3 flex items-center gap-3 shadow-sm">
<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-2xl bg-white/10 text-2xl active:bg-white/20"
className="min-w-[48px] min-h-[48px] rounded-xl bg-white/10 text-2xl active:bg-white/20"
aria-label="Retour à l'accueil"
>
←

View file

@ -17,16 +17,16 @@ const NAVIGABLE = ['C7', 'S0', 'S1', 'S1T', 'S2', 'S3', 'S4', 'S5', 'C1', 'C2',
const CONTEXTES = {
accueil: {
titre: 'Accueil',
description: 'Les objets les plus utiles au quotidien.',
description: "État général et consultations courantes.",
sections: [
{ titre: 'À faire maintenant', codes: ['S5', 'C5', 'S1T', 'S0'] },
{ titre: 'Consulter', codes: ['C2', 'S1T', 'C1', 'C4'] },
{ titre: 'Mon espace', codes: ['C7', 'C9'] },
{ titre: 'À faire maintenant', codes: ['S5', 'C5', 'C9', 'S0'] },
{ titre: 'État', codes: ['S1T', 'C2', 'C1', 'C4'] },
{ titre: 'Mon espace', codes: ['C7'] },
],
},
reunion: {
titre: 'Réunion',
description: 'Ce qui sert pendant une réunion régulière.',
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'] },
@ -34,8 +34,8 @@ const CONTEXTES = {
],
},
affaires: {
titre: 'Affaires',
description: 'Décisions, assemblées et vie de groupe.',
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'] },
@ -44,7 +44,7 @@ const CONTEXTES = {
},
gestion: {
titre: 'Gestion',
description: 'Administration, finances et structure du groupe.',
description: 'Modules de gestion et administration.',
sections: [
{ titre: 'Finances', codes: ['S1T', 'S5', 'M4'] },
{ titre: 'Membres et structure', codes: ['G2', 'G11', 'G12', 'G9', 'G13'] },
@ -64,15 +64,15 @@ function Tuile({ module, onClick }) {
return (
<button
onClick={onClick}
className={`w-full min-h-[72px] p-4 rounded-2xl border-2 text-left active:scale-[0.97] transition ${cls}`}
className={`w-full min-h-[76px] p-3 rounded-xl border text-left active:scale-[0.98] transition shadow-sm ${cls}`}
>
<div className="flex items-center gap-3">
<span className="text-2xl">{module.icone}</span>
<span className="text-2xl shrink-0">{module.icone}</span>
<div className="flex-1 min-w-0">
<p className="text-base font-bold leading-tight">{module.nom}</p>
<p className="text-xs opacity-70 line-clamp-2">{module.description}</p>
</div>
<span className="text-xs font-mono opacity-40">{module.code}</span>
<span className="text-lg opacity-40">›</span>
</div>
</button>
);
@ -82,6 +82,7 @@ export default function Accueil() {
const {
membre,
postes,
logout,
modulesConsultation,
modulesSaisie,
modulesGestion,
@ -122,15 +123,15 @@ export default function Accueil() {
return (
<div className="min-h-screen bg-gray-100">
<div className="bg-aa-blue text-white p-4 pb-5 rounded-b-3xl shadow-lg">
<div className="bg-aa-blue text-white px-4 pt-4 pb-5 rounded-b-2xl shadow-lg">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<p className="text-blue-200 text-xs">Bonjour {membre?.prenom}</p>
<h1 className="text-xl font-bold leading-tight">{contexte.titre}</h1>
<p className="text-sm text-blue-100 mt-1">{contexte.description}</p>
</div>
<button onClick={() => setPage('profil')} className="tap-target px-3 rounded-xl bg-white/10 text-blue-100 text-sm font-bold active:bg-white/20">
Profil
<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
</button>
</div>
{postes.length > 0 && (
@ -144,7 +145,7 @@ export default function Accueil() {
)}
</div>
<div className="p-4 space-y-5">
<div className="p-3 space-y-4">
<input
type="search"
value={recherche}
@ -163,9 +164,9 @@ export default function Accueil() {
Aucun module trouvé
</div>
) : (
<div className="grid gap-2">
{resultatsRecherche.map((m) => <Tuile key={m.code} module={m} onClick={() => ouvrirModule(m)} />)}
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{resultatsRecherche.map((m) => <Tuile key={m.code} module={m} onClick={() => ouvrirModule(m)} />)}
</div>
)}
</section>
) : (
@ -180,7 +181,7 @@ export default function Accueil() {
<h2 className="text-xs font-bold text-gray-400 uppercase tracking-wider px-1">
{section.titre}
</h2>
<div className="grid gap-2">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{section.modules.map((m) => <Tuile key={m.code} module={m} onClick={() => ouvrirModule(m)} />)}
</div>
</section>

View file

@ -19,9 +19,10 @@ const TYPES = {
};
export default function Decisions() {
const { postes } = useStore();
const { postes, estExecutif } = useStore();
const [propositions, setPropositions] = useState([]);
const [resolutions, setResolutions] = useState([]);
const [membres, setMembres] = useState([]);
const [tab, setTab] = useState('cours');
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
@ -29,23 +30,25 @@ export default function Decisions() {
const [sujet, setSujet] = useState('');
const [description, setDescription] = useState('');
const [resolutionId, setResolutionId] = useState('');
const [resolutionForm, setResolutionForm] = useState({});
const [seconderPour, setSeconderPour] = useState({});
const [decisionOuverte, setDecisionOuverte] = useState(null);
const estExecutif = postes.some((p) =>
const peutExecutif = estExecutif || postes.some((p) =>
['RSG', 'RSG Substitut(e)', 'Trésorier(ière)', 'Secrétaire'].includes(p)
);
const charger = async () => {
setLoading(true);
try {
const [props, res] = await Promise.all([
const [props, res, membresActifs] = await Promise.all([
api.get('/propositions/?limite=100'),
api.get('/propositions/resolutions'),
api.get('/membres/actifs'),
]);
setPropositions(props);
setResolutions(res);
if (res.length > 0 && !resolutionId) setResolutionId(res[0].id);
setMembres(membresActifs);
} catch (e) { setError(e.message); }
setLoading(false);
};
@ -55,7 +58,7 @@ export default function Decisions() {
const flash = (m) => { setMsg(m); setTimeout(() => setMsg(''), 2500); };
const soumettre = async () => {
if (!sujet.trim()) { setError('Le sujet est obligatoire'); return; }
if (!sujet.trim()) { setError("L'objet est obligatoire"); return; }
setError('');
try {
await api.post('/propositions/', {
@ -70,10 +73,11 @@ export default function Decisions() {
} catch (e) { setError(e.message); }
};
const seconder = async (id) => {
const seconder = async (id, membreId = '') => {
setError('');
try {
await api.post(`/propositions/${id}/seconder`);
const query = membreId ? `?membre_id=${membreId}` : '';
await api.post(`/propositions/${id}/seconder${query}`);
flash('Proposition secondée');
await charger();
} catch (e) { setError(e.message); }
@ -110,15 +114,23 @@ export default function Decisions() {
const rejeter = async (id) => {
setError('');
const form = resolutionForm[id] || {};
const etantDonneQue = (form.etant_donne_que || '').trim();
if (!etantDonneQue) {
setError('La section "ÉTANT DONNÉ QUE" est obligatoire pour rejeter une proposition');
return;
}
try {
await api.patch(`/propositions/${id}/voter`, { statut: 'rejetee' });
await api.patch(`/propositions/${id}/voter`, {
statut: 'rejetee',
etant_donne_que: etantDonneQue,
});
flash('Proposition rejetée');
await charger();
} catch (e) { setError(e.message); }
};
const enCours = propositions.filter((p) => ['proposee', 'secondee'].includes(p.statut));
const resolutionChoisie = resolutions.find((r) => r.id === resolutionId);
if (loading) return <div className="flex items-center justify-center min-h-screen"><p className="text-gray-400">Chargement...</p></div>;
@ -160,6 +172,7 @@ export default function Decisions() {
const statut = STATUTS[p.statut] || STATUTS.proposee;
const form = resolutionForm[p.id] || {};
const resolutionComplete = (form.etant_donne_que || '').trim() && (form.groupe_a_decide_de || '').trim();
const motifRejetPresent = (form.etant_donne_que || '').trim();
return (
<div key={p.id} className="bg-white rounded-2xl p-4 shadow-sm">
<div className="flex items-start justify-between gap-3">
@ -178,21 +191,44 @@ export default function Decisions() {
</div>
{p.statut === 'proposee' && (
<div className="grid grid-cols-2 gap-2 mt-3">
{estExecutif && (
<button onClick={() => rejeter(p.id)}
<div className="mt-3 space-y-2">
{peutExecutif && (
<textarea value={form.etant_donne_que || ''}
onChange={(e) => mettreAJourResolution(p.id, 'etant_donne_que', e.target.value)}
placeholder="ÉTANT DONNÉ QUE..."
rows={2}
className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue resize-none text-sm" />
)}
<div className="grid grid-cols-2 gap-2">
{peutExecutif && (
<button onClick={() => rejeter(p.id)} disabled={!motifRejetPresent}
className="py-2 bg-red-50 text-red-700 rounded-xl text-sm font-bold border border-red-200 active:scale-[0.97] transition">
Rejeter
</button>
)}
<button onClick={() => seconder(p.id)}
className={`${estExecutif ? '' : 'col-span-2'} py-2 bg-blue-50 text-blue-700 rounded-xl text-sm font-bold border border-blue-200 active:scale-[0.97] transition`}>
Seconder
className={`${peutExecutif ? '' : 'col-span-2'} py-2 bg-blue-50 text-blue-700 rounded-xl text-sm font-bold border border-blue-200 active:scale-[0.97] transition`}>
Je seconde
</button>
</div>
</div>
)}
{p.statut === 'proposee' && peutExecutif && (
<div className="mt-2 grid grid-cols-[1fr_auto] gap-2">
<select value={seconderPour[p.id] || ''} onChange={(e) => setSeconderPour({ ...seconderPour, [p.id]: e.target.value })}
className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue text-sm">
<option value="">Seconder pour...</option>
{membres.map((m) => <option key={m.id} value={m.id}>{m.prenom}</option>)}
</select>
<button onClick={() => seconder(p.id, seconderPour[p.id])} disabled={!seconderPour[p.id]}
className="px-4 bg-blue-600 text-white rounded-xl text-sm font-bold disabled:opacity-40">
OK
</button>
</div>
)}
{p.statut === 'secondee' && estExecutif && (
{p.statut === 'secondee' && peutExecutif && (
<div className="mt-3 space-y-3">
<div>
<label className="text-xs text-gray-400 block mb-1">ÉTANT DONNÉ QUE</label>
@ -211,8 +247,8 @@ export default function Decisions() {
className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue resize-none text-sm" />
</div>
<div className="grid grid-cols-2 gap-2">
<button onClick={() => rejeter(p.id)}
className="py-2 bg-red-50 text-red-700 rounded-xl text-sm font-bold border border-red-200 active:scale-[0.97] transition">
<button onClick={() => rejeter(p.id)} disabled={!motifRejetPresent}
className="py-2 bg-red-50 text-red-700 rounded-xl text-sm font-bold border border-red-200 disabled:opacity-40 active:scale-[0.97] transition">
Rejeter
</button>
<button onClick={() => transformer(p.id)} disabled={!resolutionComplete}
@ -232,9 +268,9 @@ export default function Decisions() {
{tab === 'nouvelle' && (
<div className="space-y-4">
<div className="bg-white rounded-2xl p-4 shadow-sm">
<label className="text-xs text-gray-400 block mb-1">Sujet</label>
<label className="text-xs text-gray-400 block mb-1">Objet</label>
<input type="text" value={sujet} onChange={(e) => setSujet(e.target.value)}
placeholder="Sujet de la proposition"
placeholder="Objet de la proposition"
className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue"
autoFocus />
</div>
@ -261,53 +297,57 @@ export default function Decisions() {
<p className="text-gray-500">Aucune résolution</p>
</div>
) : (
<div className="space-y-4">
<div className="bg-white rounded-2xl p-4 shadow-sm">
<label className="text-xs text-gray-400 block mb-1">Historique des résolutions</label>
<select value={resolutionId} onChange={(e) => setResolutionId(e.target.value)}
className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue">
{resolutions.map((r) => (
<option key={r.id} value={r.id}>
{(r.date_adoption || r.date_vote || r.cree_le?.slice(0, 10) || 'Sans date')} · {r.numero_resolution || 'RES'} · {r.sujet}
</option>
))}
</select>
</div>
{resolutionChoisie && (
<div className="bg-white rounded-2xl p-4 shadow-sm">
<div className="space-y-3">
{resolutions.map((decision) => {
const ouverte = decisionOuverte === decision.id;
const statut = STATUTS[decision.statut] || STATUTS.adoptee;
return (
<div key={decision.id} className={`bg-white rounded-xl p-4 shadow-sm border ${ouverte ? 'border-aa-blue/30' : 'border-transparent'}`}>
<button type="button" onClick={() => setDecisionOuverte(ouverte ? null : decision.id)}
className="w-full text-left">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-xs font-mono text-aa-blue font-bold">{resolutionChoisie.numero_resolution || 'Résolution'}</p>
<p className="font-bold text-gray-800">{resolutionChoisie.sujet}</p>
<div className="min-w-0">
<p className="text-xs font-mono text-aa-blue font-bold">
{decision.numero_resolution || (decision.statut === 'rejetee' ? 'REJETÉE' : 'Résolution')}
</p>
<p className="font-bold text-gray-800">{decision.sujet}</p>
<p className="text-xs text-gray-400 mt-1">
{decision.date_adoption || decision.date_vote || decision.cree_le?.slice(0, 10) || 'Sans date'}
</p>
</div>
<span className="text-xs px-2 py-1 rounded-full font-medium bg-green-100 text-green-700">
Adoptée
<span className={`text-xs px-2 py-1 rounded-full font-medium whitespace-nowrap ${statut.cls}`}>
{decision.statut === 'adoptee' ? 'Adoptée' : statut.label}
</span>
</div>
{resolutionChoisie.description && <p className="text-sm text-gray-500 mt-3">{resolutionChoisie.description}</p>}
</button>
{ouverte && (
<>
{decision.description && <p className="text-sm text-gray-500 mt-3">{decision.description}</p>}
<div className="mt-3 text-xs text-gray-400 space-y-1">
<p>Type : {TYPES[resolutionChoisie.type_proposition] || resolutionChoisie.type_proposition}</p>
<p>Date : {resolutionChoisie.date_adoption || resolutionChoisie.date_vote || resolutionChoisie.cree_le?.slice(0, 10) || '—'}</p>
<p>Proposée par {resolutionChoisie.proposeur_prenom}</p>
{resolutionChoisie.secondeur_prenom && <p>Secondée par {resolutionChoisie.secondeur_prenom}</p>}
<p>Type : {TYPES[decision.type_proposition] || decision.type_proposition}</p>
<p>Date : {decision.date_adoption || decision.date_vote || decision.cree_le?.slice(0, 10) || '—'}</p>
<p>Proposée par {decision.proposeur_prenom}</p>
{decision.secondeur_prenom && <p>Secondée par {decision.secondeur_prenom}</p>}
</div>
{(resolutionChoisie.etant_donne_que || resolutionChoisie.groupe_a_decide_de || resolutionChoisie.note) && (
{(decision.etant_donne_que || decision.groupe_a_decide_de || decision.note) && (
<div className="mt-3 bg-gray-50 rounded-xl p-3">
<p className="text-xs text-gray-400 mb-1">Texte de la résolution</p>
{resolutionChoisie.etant_donne_que && (
<p className="text-sm text-gray-700 whitespace-pre-wrap"><strong>ÉTANT DONNÉ QUE</strong>{'\n'}{resolutionChoisie.etant_donne_que}</p>
<p className="text-xs text-gray-400 mb-1">{decision.statut === 'rejetee' ? 'Motif conservé' : 'Texte de la résolution'}</p>
{decision.etant_donne_que && (
<p className="text-sm text-gray-700 whitespace-pre-wrap"><strong>ÉTANT DONNÉ QUE</strong>{'\n'}{decision.etant_donne_que}</p>
)}
{resolutionChoisie.groupe_a_decide_de && (
<p className="text-sm text-gray-700 whitespace-pre-wrap mt-3"><strong>LE GROUPE A DÉCIDÉ DE</strong>{'\n'}{resolutionChoisie.groupe_a_decide_de}</p>
{decision.groupe_a_decide_de && (
<p className="text-sm text-gray-700 whitespace-pre-wrap mt-3"><strong>LE GROUPE A DÉCIDÉ DE</strong>{'\n'}{decision.groupe_a_decide_de}</p>
)}
{!resolutionChoisie.etant_donne_que && !resolutionChoisie.groupe_a_decide_de && (
<p className="text-sm text-gray-700 whitespace-pre-wrap">{resolutionChoisie.note}</p>
{!decision.etant_donne_que && !decision.groupe_a_decide_de && (
<p className="text-sm text-gray-700 whitespace-pre-wrap">{decision.note}</p>
)}
</div>
)}
</>
)}
</div>
)}
);
})}
</div>
)
)}

View file

@ -5,7 +5,7 @@ import PageHeader from '../components/PageHeader';
import SuccessCheck from '../components/SuccessCheck';
export default function Depense() {
const { membre, postes, estSysadmin } = useStore();
const { membre, estExecutif, estSysadmin } = useStore();
const [form, setForm] = useState({
montant: '', quoi: '', membre_id: '', date_depense: new Date().toISOString().slice(0, 10),
});
@ -15,10 +15,6 @@ export default function Depense() {
const [success, setSuccess] = useState(false);
const [error, setError] = useState('');
const estExecutif = postes.some((p) =>
['RSG', 'RSG Substitut(e)', 'Trésorier(ière)', 'Secrétaire'].includes(p)
);
const charger = async () => {
try {
if (estExecutif) {

View file

@ -11,26 +11,21 @@ const METHODES = [
{ value: 'CHEQUE', label: 'Chèque', emoji: '📝' },
];
const DESTINATIONS = [
{ value: 'district', label: 'District 87-16' },
{ value: 'region', label: 'Région 87' },
{ value: 'bsg', label: 'BSG' },
{ value: 'intergroupe', label: 'Intergroupe' },
];
export default function EnvoiContributions() {
const { groupe, estSysadmin } = useStore();
const choixDates = datesReunion(groupe, { joursAvant: 365, joursApres: 180 });
const [soldes, setSoldes] = useState(null);
const [membres, setMembres] = useState([]);
const [envois, setEnvois] = useState([]);
const [destinataires, setDestinataires] = useState([]);
const [nouveauDestinataire, setNouveauDestinataire] = useState('');
const [success, setSuccess] = useState(false);
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const [form, setForm] = useState({
date_envoi: dateLocale(),
destination: 'district',
destination: 'District 87-16',
montant: '',
methode: '',
proposeur_id: '',
@ -42,10 +37,14 @@ export default function EnvoiContributions() {
api.get('/collectes/soldes'),
api.get('/membres/actifs'),
api.get('/contributions/?limite=10'),
]).then(([s, m, e]) => {
api.get('/contributions/destinataires'),
]).then(([s, m, e, d]) => {
setSoldes(s);
setMembres(m);
setEnvois(e);
setDestinataires(d);
const defaut = d.find((x) => x.nom === 'District 87-16') || d[0];
if (defaut) setForm((f) => ({ ...f, destination: defaut.nom }));
if (s && s.disponible > 0) {
setForm((f) => ({ ...f, montant: s.disponible.toFixed(2) }));
}
@ -60,6 +59,19 @@ export default function EnvoiContributions() {
const update = (k, v) => setForm({ ...form, [k]: v });
const ajouterDestinataire = async () => {
const nom = nouveauDestinataire.trim();
if (!nom) return;
setError('');
try {
const dest = await api.post('/contributions/destinataires', { nom });
const liste = await api.get('/contributions/destinataires');
setDestinataires(liste);
setForm({ ...form, destination: dest.nom });
setNouveauDestinataire('');
} catch (e) { setError(e.message); }
};
const handleSubmit = async () => {
if (!form.montant || parseFloat(form.montant) <= 0) { setError('Montant invalide'); return; }
if (!form.methode) { setError('Sélectionnez une méthode'); return; }
@ -83,7 +95,7 @@ export default function EnvoiContributions() {
return (
<div className="min-h-screen bg-gray-100">
<PageHeader titre="Envoi contributions" sousTitre="M4 — Crée la résolution" />
<PageHeader titre="Donner au suivant" sousTitre="M4 — Crée la décision de contribution" />
<div className="p-4 space-y-4">
{/* Somme disponible */}
@ -140,15 +152,20 @@ export default function EnvoiContributions() {
)}
</div>
<div className="bg-white rounded-2xl p-4 shadow-sm">
<label className="text-xs text-gray-400 block mb-2">Destinataire</label>
<div className="grid grid-cols-2 gap-2">
{DESTINATIONS.map((d) => (
<button key={d.value} type="button" onClick={() => update('destination', d.value)}
className={`p-3 rounded-xl text-sm font-bold border-2 ${
form.destination === d.value ? 'bg-aa-blue text-white border-aa-blue' : 'bg-gray-50 text-gray-600 border-gray-200'
}`}>{d.label}</button>
))}
<div className="bg-white rounded-2xl p-4 shadow-sm space-y-3">
<label className="text-xs text-gray-400 block">Destinataire</label>
<select value={form.destination} onChange={(e) => update('destination', e.target.value)}
className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue">
{destinataires.map((d) => <option key={d.id} value={d.nom}>{d.nom}</option>)}
</select>
<div className="flex gap-2">
<input type="text" value={nouveauDestinataire} onChange={(e) => setNouveauDestinataire(e.target.value)}
placeholder="Ajouter un destinataire"
className="flex-1 p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue" />
<button type="button" onClick={ajouterDestinataire}
className="px-4 py-3 bg-gray-100 text-gray-700 rounded-xl text-sm font-bold">
Ajouter
</button>
</div>
</div>

View file

@ -11,7 +11,7 @@ const CATEGORIES = [
const MODULES = [
['S0', 'Rencontre hebdomadaire'], ['M0', "Assemblée d'affaires"],
['S1', 'Collecte 7e'], ['C5', 'Gouvernance'],
['S1T', 'Trésorerie'], ['M4', 'Envoi contributions'],
['S1T', 'Trésorerie'], ['M4', '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'],
@ -24,7 +24,7 @@ const GROUPES_MODULES = [
{ titre: 'Affaires', codes: ['M0', 'M1', 'M2', 'M3', 'C5'] },
{ titre: 'Finances', codes: ['S1T', 'S5', 'M4'] },
{ titre: 'Inventaire', codes: ['C4', 'M5', 'M6'] },
{ titre: 'Administration', codes: ['G2', 'G9', 'G11', 'G12'] },
{ titre: 'Administration', codes: ['G2', 'G9', 'G11', 'G12', 'G13'] },
];
export default function GererPostes() {
@ -33,6 +33,9 @@ export default function GererPostes() {
const [candidatures, setCandidatures] = useState([]);
const [tab, setTab] = useState('postes');
const [posteOuvert, setPosteOuvert] = useState(null);
const [posteDetail, setPosteDetail] = useState(null);
const [posteAttentes, setPosteAttentes] = useState(null);
const [posteEdition, setPosteEdition] = useState(null);
const [showForm, setShowForm] = useState(false);
const [msg, setMsg] = useState('');
const [error, setError] = useState('');
@ -102,6 +105,32 @@ export default function GererPostes() {
} catch (e) { setError(e.message); }
};
const ouvrirEdition = (p) => {
setPosteEdition(p.id);
setNom(p.nom || '');
setCategorie(p.categorie || 'service');
setDescription(p.description || '');
setPrerequis(p.prerequis || '');
setInformations(p.informations || '');
};
const modifierPoste = async (id) => {
if (!nom.trim()) { setError('Nom requis'); return; }
setError('');
try {
await api.patch(`/postes/${id}`, {
nom: nom.trim(),
categorie,
description: description.trim() || null,
prerequis: prerequis.trim() || null,
informations: informations.trim() || null,
});
setPosteEdition(null); setNom(''); setDescription(''); setPrerequis(''); setInformations('');
flash('Poste modifié ✓');
await charger();
} catch (e) { setError(e.message); }
};
const toggleModule = async (posteId, code) => {
const actuel = new Set(modulesPostes[posteId] || []);
if (actuel.has(code)) actuel.delete(code);
@ -134,19 +163,19 @@ export default function GererPostes() {
return (
<div className="min-h-screen bg-gray-100">
<PageHeader titre="Gestion des postes" sousTitre="Postes, candidatures, accès aux modules" />
<PageHeader titre="Gestion des postes" sousTitre={`${postes.length} postes · ${candidatures.length} candidatures`} />
<div className="p-4 space-y-4">
<div className="p-3 space-y-3">
{msg && <div className="bg-green-50 text-green-700 p-3 rounded-xl text-center text-sm font-medium">{msg}</div>}
{error && <div className="bg-red-50 text-red-600 p-3 rounded-xl text-center text-sm">{error}</div>}
<div className="flex gap-1 overflow-x-auto">
<div className="grid grid-cols-2 gap-2">
{[
['postes', `Postes (${postes.length})`],
['candidatures', `Candidatures (${candidatures.length})`],
].map(([t, label]) => (
<button key={t} onClick={() => { setTab(t); setError(''); }}
className={`flex-1 py-2 rounded-xl text-xs font-bold transition whitespace-nowrap px-2 ${
className={`min-h-[44px] rounded-xl text-xs font-bold transition whitespace-nowrap px-2 ${
tab === t ? 'bg-aa-blue text-white' : 'bg-white text-gray-500'
}`}>{label}</button>
))}
@ -155,7 +184,7 @@ export default function GererPostes() {
{tab === 'postes' && (
<div className="space-y-3">
<button onClick={() => setShowForm(!showForm)}
className="w-full py-3 bg-green-50 text-green-700 rounded-xl font-bold border border-green-200">
className="w-full min-h-[52px] py-3 bg-green-50 text-green-700 rounded-xl font-bold border border-green-200">
+ Créer un poste
</button>
{showForm && (
@ -177,41 +206,94 @@ export default function GererPostes() {
</div>
)}
{postes.map((p) => (
<div key={p.id} className={`bg-white rounded-2xl p-4 shadow-sm ${!p.actif ? 'border-2 border-dashed border-gray-300' : ''}`}>
<div key={p.id} className={`bg-white rounded-xl p-4 shadow-sm border ${!p.actif ? 'border-dashed border-gray-300' : posteDetail === p.id ? 'border-aa-blue/30' : 'border-transparent'}`}>
<button type="button" onClick={() => setPosteDetail(posteDetail === p.id ? null : p.id)}
className="w-full text-left">
<div className="flex items-start justify-between gap-2">
<div className="flex-1">
<p className="font-bold text-gray-800">{CAT_ICON[p.categorie] || '⚪'} {p.nom}</p>
{p.description && <p className="text-xs text-gray-500 mt-1">{p.description}</p>}
{p.prerequis && <p className="text-xs text-gray-500 mt-1"><span className="font-bold">Prérequis :</span> {p.prerequis}</p>}
{p.informations && <p className="text-xs text-gray-400 mt-1 italic">{p.informations}</p>}
</div>
{p.actif ? (
<button onClick={() => abolir(p.id, p.nom)} className="text-xs text-red-500 font-medium px-2 py-1">Abolir</button>
) : (
<div className="flex flex-col gap-1">
<button onClick={() => reactiver(p.id)} className="text-xs text-green-600 font-medium px-2 py-1">Réactiver</button>
<button onClick={() => supprimerPermanent(p.id, p.nom)} className="text-xs text-red-500 font-medium px-2 py-1">Supprimer</button>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span>{CAT_ICON[p.categorie] || '⚪'}</span>
<p className="font-bold text-gray-800 truncate">{p.nom}</p>
{!p.actif && <span className="text-[11px] bg-gray-100 text-gray-500 px-2 py-0.5 rounded-full">Aboli</span>}
</div>
)}
{p.description && <p className="text-xs text-gray-500 mt-1 line-clamp-2">{p.description}</p>}
</div>
<span className="text-xl text-gray-300">{posteDetail === p.id ? '⌃' : '›'}</span>
</div>
</button>
{posteDetail === p.id && (
<div className="mt-3 pt-3 border-t border-gray-100 space-y-3 text-sm text-gray-600">
{p.prerequis && <p><span className="font-bold">Prérequis :</span> {p.prerequis}</p>}
{p.informations && <p className="whitespace-pre-wrap">{p.informations}</p>}
{!p.prerequis && !p.informations && <p>Aucun attribut additionnel.</p>}
</div>
)}
<div className="mt-3 pt-3 border-t border-gray-100">
<button
type="button"
onClick={() => setPosteOuvert(posteOuvert === p.id ? null : p.id)}
className={`w-full min-h-[44px] rounded-xl text-sm font-bold border ${
posteOuvert === p.id ? 'bg-aa-blue text-white border-aa-blue' : 'bg-gray-50 text-gray-700 border-gray-200'
}`}
>
Rôles associés ({(modulesPostes[p.id] || []).length})
</button>
<div className="grid grid-cols-2 gap-2">
<button type="button" onClick={() => setPosteOuvert(posteOuvert === p.id ? null : p.id)}
className={`min-h-[48px] rounded-xl text-sm font-bold border ${
posteOuvert === p.id ? 'bg-aa-blue text-white border-aa-blue' : 'bg-gray-50 text-gray-700 border-gray-200'
}`}>
Permissions
</button>
<button type="button" onClick={() => setPosteAttentes(posteAttentes === p.id ? null : p.id)}
className="min-h-[48px] rounded-xl text-sm font-bold border bg-gray-50 text-gray-700 border-gray-200">
Les attentes
</button>
<button type="button" onClick={() => ouvrirEdition(p)}
className="min-h-[48px] rounded-xl text-sm font-bold border bg-blue-50 text-blue-700 border-blue-100">
Modifier
</button>
{p.actif ? (
<button type="button" onClick={() => abolir(p.id, p.nom)}
className="min-h-[48px] rounded-xl text-sm font-bold border bg-red-50 text-red-700 border-red-100">
Abolir
</button>
) : (
<button type="button" onClick={() => reactiver(p.id)}
className="min-h-[48px] rounded-xl text-sm font-bold border bg-green-50 text-green-700 border-green-100">
Réactiver
</button>
)}
</div>
{posteAttentes === p.id && (
<div className="mt-3 rounded-xl bg-gray-50 p-3 text-sm text-gray-600 whitespace-pre-wrap">
{p.informations || p.prerequis || 'Aucune attente renseignée.'}
</div>
)}
{posteEdition === p.id && (
<div className="mt-3 bg-gray-50 rounded-2xl p-3 space-y-3">
<input value={nom} onChange={(e) => setNom(e.target.value)} placeholder="Nom du poste"
className="w-full p-3 bg-white rounded-xl border-0 focus:ring-2 focus:ring-aa-blue" />
<select value={categorie} onChange={(e) => setCategorie(e.target.value)}
className="w-full p-3 bg-white rounded-xl border-0 focus:ring-2 focus:ring-aa-blue">
{CATEGORIES.map((c) => <option key={c.value} value={c.value}>{c.label}</option>)}
</select>
<input value={description} onChange={(e) => setDescription(e.target.value)} placeholder="Description"
className="w-full p-3 bg-white rounded-xl border-0 focus:ring-2 focus:ring-aa-blue" />
<input value={prerequis} onChange={(e) => setPrerequis(e.target.value)} placeholder="Prérequis"
className="w-full p-3 bg-white rounded-xl border-0 focus:ring-2 focus:ring-aa-blue" />
<textarea value={informations} onChange={(e) => setInformations(e.target.value)} placeholder="Attentes"
rows={3} className="w-full p-3 bg-white rounded-xl border-0 focus:ring-2 focus:ring-aa-blue resize-none" />
<div className="flex gap-2">
<button onClick={() => modifierPoste(p.id)}
className="flex-1 py-3 bg-aa-blue text-white rounded-xl text-sm font-bold">Enregistrer</button>
<button onClick={() => setPosteEdition(null)}
className="flex-1 py-3 bg-gray-200 text-gray-700 rounded-xl text-sm font-bold">Annuler</button>
</div>
</div>
)}
{posteOuvert === p.id && (
<div className="mt-3 space-y-4">
{groupesPourPoste(p.id).map((groupe) => (
<div key={groupe.titre} className="space-y-2">
<p className="text-xs font-bold text-gray-400 uppercase tracking-wider">{groupe.titre}</p>
<div className="grid grid-cols-2 gap-2">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{groupe.modules.map(([code, label, actif]) => (
<button
key={code}
@ -230,6 +312,12 @@ export default function GererPostes() {
))}
</div>
)}
{!p.actif && (
<button onClick={() => supprimerPermanent(p.id, p.nom)}
className="w-full mt-2 py-2 bg-red-50 text-red-700 rounded-xl text-sm font-bold border border-red-100">
Supprimer définitivement
</button>
)}
</div>
</div>
))}

View file

@ -10,21 +10,27 @@ const CAT_STYLE = {
export default function Impliquer() {
const [postes, setPostes] = useState([]);
const [membres, setMembres] = useState([]);
const [mesCandidatures, setMesCandidatures] = useState([]);
const [loading, setLoading] = useState(true);
const [msg, setMsg] = useState('');
const [error, setError] = useState('');
const [motivation, setMotivation] = useState('');
const [postulePour, setPostulePour] = useState(null);
const [posteOuvert, setPosteOuvert] = useState(null);
const [mode, setMode] = useState(null);
const [membreProposeId, setMembreProposeId] = useState('');
const charger = async () => {
try {
const [p, mc] = await Promise.all([
const [p, mc, m] = await Promise.all([
api.get('/postes/disponibles'),
api.get('/postes/candidatures/mes'),
api.get('/membres/actifs'),
]);
setPostes(p);
setMesCandidatures(mc);
setMembres(m);
if (m.length > 0 && !membreProposeId) setMembreProposeId(m[0].id);
} catch (e) { setError(e.message); }
setLoading(false);
};
@ -37,12 +43,23 @@ export default function Impliquer() {
setError('');
try {
await api.post(`/postes/candidatures?poste_id=${posteId}&motivation=${encodeURIComponent(motivation.trim())}`);
setMotivation(''); setPostulePour(null);
setMotivation(''); setMode(null);
flash('Candidature soumise ✓');
await charger();
} catch (e) { setError(e.message); }
};
const proposer = async (posteId) => {
setError('');
if (!membreProposeId) { setError('Sélectionnez un membre'); return; }
try {
await api.post(`/postes/candidatures/proposer?poste_id=${posteId}&membre_id=${membreProposeId}&motivation=${encodeURIComponent(motivation.trim())}`);
setMotivation(''); setMode(null);
flash('Membre proposé ✓');
await charger();
} catch (e) { setError(e.message); }
};
const retirer = async (candId) => {
try {
await api.post(`/postes/candidatures/${candId}/retirer`);
@ -66,9 +83,9 @@ export default function Impliquer() {
return (
<div className="min-h-screen bg-gray-100">
<PageHeader titre="Postes à pourvoir" sousTitre="Postes vacants, candidatures, nominations" />
<PageHeader titre="Postes à pourvoir" sousTitre={`${postes.length} poste${postes.length > 1 ? 's' : ''} disponible${postes.length > 1 ? 's' : ''}`} />
<div className="p-4 space-y-4">
<div className="p-3 space-y-3">
{msg && <div className="bg-green-50 text-green-700 p-3 rounded-xl text-center text-sm font-medium">{msg}</div>}
{error && <div className="bg-red-50 text-red-600 p-3 rounded-xl text-center text-sm">{error}</div>}
@ -111,13 +128,6 @@ export default function Impliquer() {
</div>
)}
{/* Postes disponibles */}
<div className="bg-blue-50 rounded-xl p-3 border border-blue-200">
<p className="text-xs text-blue-700">
«Nos leaders sont des serviteurs de confiance; ils ne gouvernent pas.» — 2e Tradition
</p>
</div>
{postes.length === 0 ? (
<div className="text-center py-8">
<p className="text-4xl mb-2">✅</p>
@ -127,43 +137,82 @@ export default function Impliquer() {
<div className="space-y-3">
{postes.map((p) => {
const cat = CAT_STYLE[p.categorie] || CAT_STYLE.physique;
const ouvert = postulePour === p.id;
const ouvert = posteOuvert === p.id;
return (
<div key={p.id} className="bg-white rounded-2xl p-4 shadow-sm">
<div className="flex items-start justify-between gap-2">
<div className="flex-1">
<p className="font-bold text-gray-800">{p.nom}</p>
{p.description && <p className="text-sm text-gray-500 mt-1">{p.description}</p>}
{p.prerequis && <p className="text-xs text-gray-400 mt-1">Prérequis : {p.prerequis}</p>}
<div key={p.id} className={`bg-white rounded-xl shadow-sm overflow-hidden border ${ouvert ? 'border-aa-blue/30' : 'border-transparent'}`}>
<button type="button" onClick={() => { setPosteOuvert(ouvert ? null : p.id); setMode(null); setMotivation(''); }}
className="w-full p-4 text-left">
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className="font-bold text-gray-800 truncate">{p.nom}</p>
<span className={`text-[11px] px-2 py-0.5 rounded-full font-medium whitespace-nowrap ${cat.cls}`}>{cat.label}</span>
</div>
{p.description && <p className="text-sm text-gray-500 mt-1 line-clamp-2">{p.description}</p>}
<p className="text-xs text-gray-400 mt-2">
{p.candidats?.length || 0} candidat{(p.candidats?.length || 0) > 1 ? 's' : ''}
</p>
</div>
<span className="text-xl text-gray-300">{ouvert ? '⌃' : '›'}</span>
</div>
<span className={`text-xs px-2 py-1 rounded-full font-medium whitespace-nowrap ${cat.cls}`}>{cat.label}</span>
</div>
</button>
{p.deja_postule ? (
<div className="mt-3 py-2 bg-yellow-50 rounded-xl text-center">
<p className="text-xs text-yellow-700 font-medium">Candidature déjà soumise</p>
</div>
) : ouvert ? (
<div className="mt-3 space-y-2">
{ouvert && (
<div className="px-4 pb-4 space-y-3 border-t border-gray-100">
{p.prerequis && <p className="text-sm text-gray-600"><span className="font-bold">Prérequis :</span> {p.prerequis}</p>}
{p.informations && <p className="text-sm text-gray-500 whitespace-pre-wrap">{p.informations}</p>}
<div>
<p className="text-xs font-bold text-gray-400 uppercase tracking-wider">Candidats</p>
{p.candidats?.length ? (
<div className="mt-2 flex flex-wrap gap-2">
{p.candidats.map((c) => <span key={c.id} className="text-sm text-gray-700 bg-gray-100 rounded-full px-3 py-1">{c.prenom}</span>)}
</div>
) : <p className="text-sm text-gray-500 mt-1">Aucun candidat.</p>}
</div>
{p.deja_postule && (
<div className="py-2 bg-yellow-50 rounded-xl text-center">
<p className="text-xs text-yellow-700 font-medium">Candidature déjà soumise</p>
</div>
)}
{mode && (
<div className="space-y-2">
{mode === 'proposer' && (
<select value={membreProposeId} onChange={(e) => setMembreProposeId(e.target.value)}
className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue">
{membres.map((m) => <option key={m.id} value={m.id}>{m.prenom}</option>)}
</select>
)}
<textarea value={motivation} onChange={(e) => setMotivation(e.target.value)}
placeholder="Pourquoi ce poste vous intéresse? (optionnel)"
placeholder={mode === 'proposer' ? 'Pourquoi proposer ce membre? (optionnel)' : 'Pourquoi ce poste vous intéresse? (optionnel)'}
rows={2} className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue resize-none text-sm" />
<div className="flex gap-2">
<button onClick={() => postuler(p.id)}
<button onClick={() => mode === 'proposer' ? proposer(p.id) : postuler(p.id)}
className="flex-1 py-2 bg-green-50 text-green-700 rounded-xl text-sm font-bold border border-green-200 active:scale-[0.97]">
✓ Soumettre
</button>
<button onClick={() => { setPostulePour(null); setMotivation(''); }}
<button onClick={() => { setMode(null); setMotivation(''); }}
className="flex-1 py-2 bg-gray-100 text-gray-500 rounded-xl text-sm font-bold">
Annuler
</button>
</div>
</div>
)}
{!mode && (
<div className="grid grid-cols-2 gap-2">
<button onClick={() => setMode('postuler')} disabled={p.deja_postule}
className="min-h-[52px] py-3 bg-yellow-50 text-yellow-700 rounded-xl text-sm font-bold border border-yellow-200 disabled:opacity-40">
Postuler
</button>
<button onClick={() => setMode('proposer')}
className="min-h-[52px] py-3 bg-blue-50 text-blue-700 rounded-xl text-sm font-bold border border-blue-200">
Proposer
</button>
</div>
)}
</div>
) : (
<button onClick={() => setPostulePour(p.id)}
className="w-full mt-3 py-2 bg-yellow-50 text-yellow-700 rounded-xl text-sm font-bold border border-yellow-200 active:scale-[0.97] transition">
🙋 Proposer ma candidature
</button>
)}
</div>
);

View file

@ -10,7 +10,7 @@ import {
} from '../utils/pushNotifications';
export default function Profil() {
const { setPage, logout } = useStore();
const { setPage } = useStore();
const [profil, setProfil] = useState(null);
const [editing, setEditing] = useState(false);
const [form, setForm] = useState({});
@ -264,12 +264,6 @@ export default function Profil() {
</div>
)}
</div>
{/* Déconnexion */}
<button onClick={logout}
className="w-full p-4 bg-red-50 text-red-600 rounded-2xl font-bold border border-red-200 active:scale-[0.97] transition">
Se déconnecter
</button>
</div>
</div>
);

View file

@ -30,6 +30,7 @@ export default function ReunionHebdomadaire() {
const [msg, setMsg] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const [rapportVisible, setRapportVisible] = useState(false);
const charger = async () => {
try {
@ -119,14 +120,17 @@ export default function ReunionHebdomadaire() {
{msg && <div className="bg-green-50 text-green-700 p-3 rounded-xl text-center text-sm font-medium">{msg}</div>}
{error && <div className="bg-red-50 text-red-600 p-3 rounded-xl text-center text-sm">{error}</div>}
<Card titre="Consulter le rapport">
<Card titre="Rapport imprimable">
<Champ label="Date">
<SelectDate value={dateSelection} onChange={setDateSelection} dates={choixDates} />
</Champ>
<button onClick={imprimer} className="w-full p-3 bg-gray-900 text-white rounded-xl text-sm font-bold">Imprimer format lettre</button>
<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>
<div className="bg-white rounded-2xl p-5 shadow-sm print:shadow-none print:rounded-none print:p-0">
{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 ? (
@ -155,7 +159,7 @@ export default function ReunionHebdomadaire() {
<p>Jeton : __________________</p>
<p>Littérature : __________ $</p>
</div>
</div>
</div>}
{estExecutif && (
<>

View file

@ -25,6 +25,7 @@ export default function Tresorerie() {
const [depensesAConfirmer, setDepensesAConfirmer] = useState([]);
const [operations, setOperations] = useState([]);
const [reserves, setReserves] = useState([]);
const [destinataires, setDestinataires] = useState([]);
const [registre, setRegistre] = useState([]);
const [tab, setTab] = useState('etat');
const [msg, setMsg] = useState('');
@ -48,6 +49,8 @@ export default function Tresorerie() {
const [transfertSourceId, setTransfertSourceId] = useState('');
const [transfertDestinationId, setTransfertDestinationId] = useState('');
const [transfertMontant, setTransfertMontant] = useState('');
const [destinataireId, setDestinataireId] = useState(null);
const [destinataireNom, setDestinataireNom] = useState('');
const registreQuery = () => {
const params = new URLSearchParams();
@ -77,12 +80,14 @@ export default function Tresorerie() {
setDepenses(deps);
setDepensesAConfirmer(depsConf);
if (estExecutif) {
const [c, o] = await Promise.all([
const [c, o, d] = await Promise.all([
api.get('/collectes/?limite=20'),
api.get('/banque/'),
api.get('/contributions/destinataires'),
]);
setCollectes(c);
setOperations(o);
setDestinataires(d);
} else {
setCollectes([]);
setOperations([]);
@ -247,6 +252,34 @@ export default function Tresorerie() {
} catch (e) { setError(e.message); }
};
const resetDestinataireForm = () => {
setDestinataireId(null);
setDestinataireNom('');
};
const enregistrerDestinataire = async () => {
const nom = destinataireNom.trim();
if (!nom) { setError('Nom de destinataire requis'); return; }
setError('');
try {
if (destinataireId) await api.patch(`/contributions/destinataires/${destinataireId}`, { nom });
else await api.post('/contributions/destinataires', { nom });
resetDestinataireForm();
setDestinataires(await api.get('/contributions/destinataires'));
flash(destinataireId ? 'Destinataire modifié ✓' : 'Destinataire ajouté ✓');
} catch (e) { setError(e.message); }
};
const supprimerDestinataire = async (id) => {
if (!window.confirm('Supprimer ce destinataire ?')) return;
setError('');
try {
await api.delete(`/contributions/destinataires/${id}`);
setDestinataires(await api.get('/contributions/destinataires'));
flash('Destinataire supprimé');
} catch (e) { setError(e.message); }
};
const periodeRapportPrecedent = () => {
const d = new Date();
d.setDate(1);
@ -581,15 +614,16 @@ export default function Tresorerie() {
<div className="bg-white rounded-2xl p-4 shadow-sm space-y-4">
<div>
<label className="text-xs text-gray-400 block mb-2">Action</label>
<div className="grid grid-cols-4 gap-1">
<div className="grid grid-cols-2 gap-2">
{[
['depot', 'Dépôt'],
['retrait', 'Retrait'],
['virement', 'Virement'],
['reserve', 'Réserve'],
['destinataire', 'Destinataires'],
].map(([val, label]) => (
<button key={val} onClick={() => setDepotType(val)}
className={`min-h-[44px] rounded-xl text-xs font-bold border ${
className={`min-h-[52px] rounded-xl text-sm font-bold border ${
depotType === val ? 'bg-aa-blue text-white border-aa-blue' : 'bg-gray-50 text-gray-600 border-gray-200'
}`}>
{label}
@ -725,6 +759,47 @@ export default function Tresorerie() {
</div>
</>
)}
{depotType === 'destinataire' && (
<div className="space-y-3">
<div className="flex gap-2">
<input type="text" value={destinataireNom} onChange={(e) => setDestinataireNom(e.target.value)}
placeholder="Nom du destinataire"
className="flex-1 p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue" />
<button onClick={enregistrerDestinataire}
className="px-4 py-3 bg-aa-blue text-white rounded-xl text-sm font-bold">
{destinataireId ? 'Modifier' : 'Ajouter'}
</button>
</div>
{destinataireId && (
<button onClick={resetDestinataireForm} className="w-full p-2 bg-gray-100 text-gray-600 rounded-xl text-sm font-bold">
Annuler la modification
</button>
)}
<div className="space-y-2">
{destinataires.map((d) => (
<div key={d.id} className="flex items-center justify-between gap-3 rounded-xl bg-gray-50 p-3">
<div className="min-w-0">
<p className="font-bold text-gray-800 truncate">{d.nom}</p>
{d.protege && <p className="text-xs text-gray-400">Protégé</p>}
</div>
<div className="flex gap-2 shrink-0">
<button onClick={() => { setDestinataireId(d.id); setDestinataireNom(d.nom); }}
disabled={d.protege}
className="px-3 py-2 bg-blue-50 text-blue-700 rounded-xl text-xs font-bold disabled:opacity-40">
Modifier
</button>
<button onClick={() => supprimerDestinataire(d.id)}
disabled={d.protege}
className="px-3 py-2 bg-red-50 text-red-700 rounded-xl text-xs font-bold disabled:opacity-40">
Supprimer
</button>
</div>
</div>
))}
</div>
</div>
)}
</div>
<div className="bg-white rounded-2xl p-4 shadow-sm">