Ameliorer gouvernance tresorerie et seed initial
This commit is contained in:
parent
a936e7ed81
commit
56b69b1bb8
26 changed files with 979 additions and 228 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
20
backend/app/models/destinataire_contribution.py
Normal file
20
backend/app/models/destinataire_contribution.py
Normal 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))
|
||||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
166
data/import/Liste-de-prix-rev.-Fevrier-2026.csv
Normal file
166
data/import/Liste-de-prix-rev.-Fevrier-2026.csv
Normal 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
|
||||
|
BIN
data/import/Liste-de-prix-rev.-Fevrier-2026.pdf
Normal file
BIN
data/import/Liste-de-prix-rev.-Fevrier-2026.pdf
Normal file
Binary file not shown.
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
}`}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
←
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
Loading…
Reference in a new issue