feat: automate deployment and update group workflows

This commit is contained in:
Daniel Allaire 2026-05-15 16:41:17 -04:00
parent 2245f26492
commit 5e5c38fb21
67 changed files with 8991 additions and 1031 deletions

4
.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
node_modules/
dist/
*.retry
ansible/group_vars/vault.yml

View file

@ -48,17 +48,14 @@ Application mobile-first pour la gestion des groupes AA. 1 module = 1 tâche.
- **M5** Ajuster inventaire — +/−, ajout catalogue
- **M6** Commander jetons — état stock 30 jours
### Gestion (G1-G9) — Exécutif
### Gestion — Exécutif
- **G1** Inviter un membre — code 48h
- **G2** Gérer les membres — fiche complète, droit à l'oubli
- **G3** Affecter les postes — n'affiche que les postes vacants
- **G4** Terminer un mandat — transition manuelle, note pour le successeur
- **G5** Planifier les rotations — calendrier serviteurs
- **G6** Lancer un sondage — oui/non, choix multiple, unanimité
- **G7** Jumeler parrain/filleul — confidentiel
- **G9** Paramètres du groupe — nom, horaire, adresse, format
- **G10** Gérer les postes — créer, abolir, candidatures
- **G11** Gestion des mandats — affecter, terminer, planifier les rotations
- **G12** Gestion des postes — créer, abolir, candidatures, accès aux modules
## Trésorerie
@ -103,6 +100,12 @@ deploy/ Service systemd, NGINX, seed, backup
docs/ DEPLOIEMENT.md, RUNBOOK.md
```
## Documentation
- [Guide utilisateur](docs/UTILISATEURS.md)
- [Déploiement Ansible](docs/ANSIBLE.md)
- [Runbook d'exploitation](docs/RUNBOOK.md)
---
*«Nos leaders sont des serviteurs de confiance ; ils ne gouvernent pas.» — 2e Tradition*

15
ansible.cfg Normal file
View file

@ -0,0 +1,15 @@
[defaults]
inventory = ansible/inventory/hosts.yml
roles_path = ansible/roles
stdout_callback = yaml
bin_ansible_callbacks = True
interpreter_python = auto_silent
host_key_checking = True
retry_files_enabled = False
[ssh_connection]
control_path_dir = /tmp/ansible-cp
[privilege_escalation]
become = True
become_method = sudo

21
ansible/backup-now.yml Normal file
View file

@ -0,0 +1,21 @@
---
- name: Executer une sauvegarde immediate
hosts: groupe_meditation
become: true
gather_facts: false
tasks:
- name: Lancer le script de sauvegarde
ansible.builtin.command: /etc/cron.daily/groupe-meditation-backup
changed_when: true
- name: Lister les dernieres sauvegardes
ansible.builtin.command:
cmd: ls -1t "{{ groupe_meditation_backup_dir }}"
register: backups
changed_when: false
- name: Afficher les sauvegardes recentes
ansible.builtin.debug:
msg: "{{ backups.stdout_lines[:5] }}"

View file

@ -0,0 +1,55 @@
---
# Domaine public servi par NGINX.
groupe_meditation_domain: groupe-meditation.87-16.org
# Chemins de déploiement.
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_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"
# Service applicatif.
groupe_meditation_service_name: groupe-meditation
groupe_meditation_service_user: www-data
groupe_meditation_service_group: www-data
groupe_meditation_api_host: 127.0.0.1
groupe_meditation_api_port: 8000
groupe_meditation_uvicorn_workers: 2
# Base de donnees locale PostgreSQL.
groupe_meditation_db_name: groupe_meditation
groupe_meditation_db_user: grpmed
groupe_meditation_db_host: 127.0.0.1
groupe_meditation_db_port: 5432
# A remplacer dans ansible/group_vars/vault.yml chiffre avec ansible-vault.
groupe_meditation_db_password: CHANGE_ME_DB_PASSWORD
groupe_meditation_jwt_secret: CHANGE_ME_JWT_SECRET
groupe_meditation_database_url: >-
postgresql+asyncpg://{{ groupe_meditation_db_user }}:{{ groupe_meditation_db_password }}@{{ groupe_meditation_db_host }}:{{ groupe_meditation_db_port }}/{{ groupe_meditation_db_name }}
groupe_meditation_cors_origins:
- "https://{{ groupe_meditation_domain }}"
groupe_meditation_invitation_expire_hours: 48
# Initialisation applicative. Le seed existant est interactif; Ansible lui
# fournit ces valeurs seulement si la base ne contient encore aucun groupe.
groupe_meditation_run_seed: true
groupe_meditation_first_member:
prenom: CHANGE_ME
nom: ""
courriel: CHANGE_ME@example.com
telephone: CHANGE_ME
pin: "1234"
poste_numero: "1"
# TLS Let's Encrypt. Activez apres que le DNS pointe vers la machine.
groupe_meditation_enable_tls: false
groupe_meditation_certbot_email: admin@example.com
# Sauvegardes.
groupe_meditation_backup_dir: /var/backups/groupe-meditation
groupe_meditation_backup_retention_days: 30

View file

@ -0,0 +1,15 @@
---
# Copier vers ansible/group_vars/vault.yml puis chiffrer:
# ansible-vault encrypt ansible/group_vars/vault.yml
groupe_meditation_db_password: "remplacer-par-un-mot-de-passe-solide"
groupe_meditation_jwt_secret: "sortie-de-openssl-rand-base64-48"
groupe_meditation_first_member:
prenom: "VotrePrenom"
nom: ""
courriel: "vous@example.com"
telephone: "000-000-0000"
pin: "1234"
poste_numero: "1"

View file

@ -0,0 +1,9 @@
---
all:
children:
groupe_meditation:
hosts:
groupe-meditation-prod:
ansible_host: 192.168.12.46
ansible_user: ansible
ansible_ssh_private_key_file: ~/.ssh/id_ed25519_ansible_chezlepro

7
ansible/requirements.yml Normal file
View file

@ -0,0 +1,7 @@
---
collections:
- name: ansible.posix
version: ">=1.5.0"
- name: community.postgresql
version: ">=3.4.0"

View file

@ -0,0 +1,10 @@
---
- name: Recharger systemd
ansible.builtin.systemd:
daemon_reload: true
- name: Redemarrer groupe-meditation
ansible.builtin.service:
name: "{{ groupe_meditation_service_name }}"
state: restarted

View file

@ -0,0 +1,196 @@
---
- name: Installer les paquets requis par l'application
ansible.builtin.apt:
name:
- build-essential
- libcairo2
- libffi-dev
- libgdk-pixbuf-2.0-0
- libpango-1.0-0
- libpangocairo-1.0-0
- python3
- python3-pip
- python3-venv
state: present
- name: Creer les repertoires applicatifs
ansible.builtin.file:
path: "{{ item.path }}"
state: directory
owner: "{{ item.owner | default(groupe_meditation_service_user) }}"
group: "{{ item.group | default(groupe_meditation_service_group) }}"
mode: "{{ item.mode | default('0755') }}"
loop:
- path: "{{ groupe_meditation_app_dir }}"
owner: root
group: root
- path: "{{ groupe_meditation_upload_dir }}"
mode: "0755"
- path: "{{ groupe_meditation_web_root }}"
- name: Synchroniser le backend
ansible.posix.synchronize:
src: "{{ playbook_dir }}/../backend/"
dest: "{{ groupe_meditation_backend_dir }}/"
delete: true
rsync_opts:
- "--exclude=venv/"
- "--exclude=.env"
- "--exclude=__pycache__/"
- "--exclude=*.pyc"
notify: Redemarrer groupe-meditation
- name: Synchroniser les fichiers de deploiement
ansible.posix.synchronize:
src: "{{ playbook_dir }}/../deploy/"
dest: "{{ groupe_meditation_deploy_dir }}/"
delete: true
notify: Redemarrer groupe-meditation
- name: Synchroniser le frontend
ansible.posix.synchronize:
src: "{{ playbook_dir }}/../frontend/"
dest: "{{ groupe_meditation_frontend_dir }}/"
delete: true
rsync_opts:
- "--exclude=node_modules/"
- "--exclude=dist/"
- name: Creer l'environnement virtuel Python
ansible.builtin.command:
cmd: python3 -m venv "{{ groupe_meditation_backend_dir }}/venv"
creates: "{{ groupe_meditation_backend_dir }}/venv/bin/python"
- name: Installer les dependances Python
ansible.builtin.pip:
requirements: "{{ groupe_meditation_backend_dir }}/requirements.txt"
virtualenv: "{{ groupe_meditation_backend_dir }}/venv"
notify: Redemarrer groupe-meditation
- name: Deployer le fichier d'environnement backend
ansible.builtin.template:
src: env.j2
dest: "{{ groupe_meditation_backend_dir }}/.env"
owner: "{{ groupe_meditation_service_user }}"
group: "{{ groupe_meditation_service_group }}"
mode: "0640"
no_log: true
notify: Redemarrer groupe-meditation
- name: Installer les dependances frontend sur le poste de controle avec npm ci
ansible.builtin.command:
cmd: npm ci
chdir: "{{ playbook_dir }}/../frontend"
delegate_to: localhost
become: false
run_once: true
when: lookup('ansible.builtin.fileglob', playbook_dir + '/../frontend/package-lock.json') | length > 0
changed_when: true
- name: Installer les dependances frontend sur le poste de controle avec npm install
ansible.builtin.command:
cmd: npm install
chdir: "{{ playbook_dir }}/../frontend"
delegate_to: localhost
become: false
run_once: true
when: lookup('ansible.builtin.fileglob', playbook_dir + '/../frontend/package-lock.json') | length == 0
changed_when: true
- name: Construire le frontend sur le poste de controle
ansible.builtin.command:
cmd: npm run build
chdir: "{{ playbook_dir }}/../frontend"
delegate_to: localhost
become: false
run_once: true
changed_when: true
- name: Deployer les fichiers statiques frontend
ansible.posix.synchronize:
src: "{{ playbook_dir }}/../frontend/dist/"
dest: "{{ groupe_meditation_web_root }}/"
delete: true
- name: Ajuster les permissions applicatives
ansible.builtin.file:
path: "{{ item }}"
owner: "{{ groupe_meditation_service_user }}"
group: "{{ groupe_meditation_service_group }}"
recurse: true
loop:
- "{{ groupe_meditation_app_dir }}"
- "{{ groupe_meditation_web_root }}"
- name: Installer l'unite systemd
ansible.builtin.template:
src: groupe-meditation.service.j2
dest: "/etc/systemd/system/{{ groupe_meditation_service_name }}.service"
owner: root
group: root
mode: "0644"
notify:
- Recharger systemd
- Redemarrer groupe-meditation
- name: Appliquer les handlers avant le seed
ansible.builtin.meta: flush_handlers
- name: Compter les groupes existants
community.postgresql.postgresql_query:
db: "{{ groupe_meditation_db_name }}"
login_user: "{{ groupe_meditation_db_user }}"
login_password: "{{ groupe_meditation_db_password }}"
login_host: "{{ groupe_meditation_db_host }}"
port: "{{ groupe_meditation_db_port }}"
query: "SELECT COUNT(*) AS count FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'groupes';"
register: groupes_table
no_log: true
- name: Compter les donnees applicatives existantes
community.postgresql.postgresql_query:
db: "{{ groupe_meditation_db_name }}"
login_user: "{{ groupe_meditation_db_user }}"
login_password: "{{ groupe_meditation_db_password }}"
login_host: "{{ groupe_meditation_db_host }}"
port: "{{ groupe_meditation_db_port }}"
query: "SELECT COUNT(*) AS count FROM groupes;"
register: groupes_count
when: groupes_table.query_result[0].count | int > 0
no_log: true
- name: Initialiser la base avec le seed existant
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
executable: /bin/bash
chdir: "{{ groupe_meditation_backend_dir }}"
when:
- groupe_meditation_run_seed | bool
- groupes_table.query_result[0].count | int == 0 or (groupes_count.query_result[0].count | default(0) | int == 0)
no_log: true
notify: Redemarrer groupe-meditation
- name: Activer et demarrer le service applicatif
ansible.builtin.service:
name: "{{ groupe_meditation_service_name }}"
state: started
enabled: true
- name: Verifier la sante locale de l'API
ansible.builtin.uri:
url: "http://{{ groupe_meditation_api_host }}:{{ groupe_meditation_api_port }}/api/health"
return_content: true
status_code: 200
register: api_health
retries: 6
delay: 5
until: api_health.status == 200

View file

@ -0,0 +1,6 @@
# Gere par Ansible.
DATABASE_URL={{ groupe_meditation_database_url }}
JWT_SECRET={{ groupe_meditation_jwt_secret }}
CORS_ORIGINS={{ groupe_meditation_cors_origins | to_json }}
INVITATION_EXPIRE_HOURS={{ groupe_meditation_invitation_expire_hours }}

View file

@ -0,0 +1,18 @@
[Unit]
Description=Groupe Meditation API
After=network.target postgresql.service
Requires=postgresql.service
[Service]
Type=simple
User={{ groupe_meditation_service_user }}
Group={{ groupe_meditation_service_group }}
WorkingDirectory={{ groupe_meditation_backend_dir }}
Environment=PATH={{ groupe_meditation_backend_dir }}/venv/bin
ExecStart={{ groupe_meditation_backend_dir }}/venv/bin/uvicorn app.main:app --host {{ groupe_meditation_api_host }} --port {{ groupe_meditation_api_port }} --workers {{ groupe_meditation_uvicorn_workers }}
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target

View file

@ -0,0 +1,18 @@
---
- name: Creer le repertoire de sauvegardes
ansible.builtin.file:
path: "{{ groupe_meditation_backup_dir }}"
state: directory
owner: root
group: root
mode: "0750"
- name: Installer la sauvegarde quotidienne
ansible.builtin.template:
src: groupe-meditation-backup.j2
dest: /etc/cron.daily/groupe-meditation-backup
owner: root
group: root
mode: "0750"
no_log: true

View file

@ -0,0 +1,18 @@
#!/bin/bash
set -euo pipefail
BACKUP_DIR="{{ groupe_meditation_backup_dir }}"
RETENTION_DAYS="{{ groupe_meditation_backup_retention_days }}"
DB_NAME="{{ groupe_meditation_db_name }}"
DB_USER="{{ groupe_meditation_db_user }}"
DB_HOST="{{ groupe_meditation_db_host }}"
DATE="$(date +%Y-%m-%d_%H%M)"
mkdir -p "$BACKUP_DIR"
export PGPASSWORD="{{ groupe_meditation_db_password }}"
pg_dump -U "$DB_USER" -h "$DB_HOST" -Fc "$DB_NAME" > "$BACKUP_DIR/grpmed_${DATE}.dump"
find "$BACKUP_DIR" -name "grpmed_*.dump" -mtime +"$RETENTION_DAYS" -delete
echo "[$(date)] Sauvegarde terminee: grpmed_${DATE}.dump"

View file

@ -0,0 +1,17 @@
---
- name: Installer Certbot
ansible.builtin.apt:
name:
- certbot
- python3-certbot-nginx
state: present
- name: Obtenir ou renouveler le certificat Let's Encrypt
ansible.builtin.command:
cmd: >-
certbot --nginx --non-interactive --agree-tos
--email {{ groupe_meditation_certbot_email }}
-d {{ groupe_meditation_domain }}
args:
creates: "/etc/letsencrypt/live/{{ groupe_meditation_domain }}/fullchain.pem"

View file

@ -0,0 +1,50 @@
---
- name: Installer les sources APT Debian standard
ansible.builtin.copy:
dest: /etc/apt/sources.list.d/debian.sources
owner: root
group: root
mode: "0644"
content: |
Types: deb
URIs: https://deb.debian.org/debian
Suites: bookworm bookworm-updates
Components: main
Signed-By: /usr/share/keyrings/debian-archive-keyring.gpg
Types: deb
URIs: https://deb.debian.org/debian-security
Suites: bookworm-security
Components: main
Signed-By: /usr/share/keyrings/debian-archive-keyring.gpg
register: debian_sources
- name: Nettoyer les anciens index APT si les sources changent
ansible.builtin.file:
path: /var/lib/apt/lists
state: absent
when: debian_sources.changed
- name: Recreer le repertoire des listes APT
ansible.builtin.file:
path: /var/lib/apt/lists/partial
state: directory
owner: _apt
group: root
mode: "0700"
- name: Mettre a jour le cache APT
ansible.builtin.apt:
update_cache: true
cache_valid_time: 0
- name: Installer les paquets systeme communs
ansible.builtin.apt:
name:
- acl
- ca-certificates
- curl
- gnupg
- rsync
- unzip
state: present

View file

@ -0,0 +1,6 @@
---
- name: Recharger nginx
ansible.builtin.service:
name: nginx
state: reloaded

View file

@ -0,0 +1,39 @@
---
- name: Installer NGINX
ansible.builtin.apt:
name: nginx
state: present
- name: Installer le site NGINX
ansible.builtin.template:
src: groupe-meditation.conf.j2
dest: /etc/nginx/sites-available/groupe-meditation
owner: root
group: root
mode: "0644"
notify: Recharger nginx
- name: Activer le site NGINX
ansible.builtin.file:
src: /etc/nginx/sites-available/groupe-meditation
dest: /etc/nginx/sites-enabled/groupe-meditation
state: link
force: true
notify: Recharger nginx
- name: Desactiver le site NGINX par defaut
ansible.builtin.file:
path: /etc/nginx/sites-enabled/default
state: absent
notify: Recharger nginx
- name: Verifier la configuration NGINX
ansible.builtin.command: nginx -t
changed_when: false
- name: Activer et demarrer NGINX
ansible.builtin.service:
name: nginx
state: started
enabled: true

View file

@ -0,0 +1,37 @@
server {
listen 80;
server_name {{ groupe_meditation_domain }};
add_header X-Content-Type-Options nosniff always;
add_header X-Frame-Options DENY always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy strict-origin-when-cross-origin always;
location /api/ {
proxy_pass http://{{ groupe_meditation_api_host }}:{{ groupe_meditation_api_port }};
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
root {{ groupe_meditation_web_root }};
index index.html;
location ~* \.(js|css|png|jpg|svg|woff2|ico)$ {
expires 1y;
add_header Cache-Control "public, immutable";
try_files $uri =404;
}
location / {
try_files $uri $uri/ /index.html;
}
location = /index.html {
add_header Cache-Control "no-cache, no-store, must-revalidate";
}
client_max_body_size 5M;
}

View file

@ -0,0 +1,51 @@
---
- name: Installer PostgreSQL et les dependances Python
ansible.builtin.apt:
name:
- postgresql
- postgresql-client
- python3-psycopg2
state: present
- name: Activer PostgreSQL
ansible.builtin.service:
name: postgresql
state: started
enabled: true
- name: Creer l'utilisateur PostgreSQL applicatif
community.postgresql.postgresql_user:
name: "{{ groupe_meditation_db_user }}"
password: "{{ groupe_meditation_db_password }}"
role_attr_flags: LOGIN
state: present
become_user: postgres
- name: Creer la base PostgreSQL applicative
community.postgresql.postgresql_db:
name: "{{ groupe_meditation_db_name }}"
owner: "{{ groupe_meditation_db_user }}"
encoding: UTF8
state: present
become_user: postgres
- name: Donner les droits sur le schema public
community.postgresql.postgresql_privs:
db: "{{ groupe_meditation_db_name }}"
type: schema
objs: public
roles: "{{ groupe_meditation_db_user }}"
privs: ALL
state: present
become_user: postgres
- name: Verifier la connexion PostgreSQL TCP locale
community.postgresql.postgresql_query:
db: "{{ groupe_meditation_db_name }}"
login_user: "{{ groupe_meditation_db_user }}"
login_password: "{{ groupe_meditation_db_password }}"
login_host: "{{ groupe_meditation_db_host }}"
port: "{{ groupe_meditation_db_port }}"
query: SELECT 1;
no_log: true

38
ansible/site.yml Normal file
View file

@ -0,0 +1,38 @@
---
- name: Installer Groupe Meditation
hosts: groupe_meditation
become: true
gather_facts: true
pre_tasks:
- name: Charger les secrets locaux si presents
ansible.builtin.include_vars:
file: "{{ playbook_dir }}/group_vars/vault.yml"
when: lookup('ansible.builtin.fileglob', playbook_dir + '/group_vars/vault.yml') | length > 0
no_log: true
- name: Valider Debian
ansible.builtin.assert:
that:
- ansible_facts.os_family == "Debian"
fail_msg: "Ce playbook cible Debian."
- name: Valider les secrets obligatoires
ansible.builtin.assert:
that:
- groupe_meditation_db_password != "CHANGE_ME_DB_PASSWORD"
- groupe_meditation_jwt_secret != "CHANGE_ME_JWT_SECRET"
- groupe_meditation_first_member.prenom != "CHANGE_ME"
- groupe_meditation_first_member.courriel != "CHANGE_ME@example.com"
- groupe_meditation_first_member.telephone != "CHANGE_ME"
- groupe_meditation_first_member.pin is match('^[0-9]{4}$')
fail_msg: "Renseignez les variables sensibles, idealement dans ansible/group_vars/vault.yml chiffre."
roles:
- common
- postgresql
- app
- nginx
- backup
- role: certbot
when: groupe_meditation_enable_tls | bool

42
ansible/verify.yml Normal file
View file

@ -0,0 +1,42 @@
---
- name: Verifier Groupe Meditation
hosts: groupe_meditation
become: true
gather_facts: false
tasks:
- name: Verifier le service applicatif
ansible.builtin.service_facts:
- name: Valider que le service est actif
ansible.builtin.assert:
that:
- ansible_facts.services[groupe_meditation_service_name + '.service'].state == 'running'
fail_msg: "Le service {{ groupe_meditation_service_name }} n'est pas actif."
- name: Verifier la sante API locale
ansible.builtin.uri:
url: "http://{{ groupe_meditation_api_host }}:{{ groupe_meditation_api_port }}/api/health"
status_code: 200
return_content: true
register: api_health
- name: Afficher la reponse API
ansible.builtin.debug:
var: api_health.json
- name: Verifier la configuration NGINX
ansible.builtin.command: nginx -t
changed_when: false
- name: Verifier la presence du frontend
ansible.builtin.stat:
path: "{{ groupe_meditation_web_root }}/index.html"
register: frontend_index
- name: Valider le frontend
ansible.builtin.assert:
that:
- frontend_index.stat.exists
fail_msg: "Le fichier {{ groupe_meditation_web_root }}/index.html est absent."

View file

@ -42,12 +42,15 @@ async def get_current_membre(
from app.models.membre import Membre
payload = decode_token(credentials.credentials)
membre_id = payload.get("sub")
groupe_id = payload.get("grp")
if not membre_id:
raise HTTPException(status_code=401, detail="Token invalide")
result = await db.execute(select(Membre).where(Membre.id == membre_id, Membre.actif == True))
membre = result.scalar_one_or_none()
if not membre:
raise HTTPException(status_code=401, detail="Membre introuvable ou inactif")
if groupe_id and str(membre.groupe_id) != str(groupe_id):
raise HTTPException(status_code=401, detail="Session invalide pour ce groupe")
return membre

View file

@ -9,7 +9,7 @@ from fastapi.middleware.cors import CORSMiddleware
from app.core.config import settings
from app.core.database import engine, Base
from app.routers import auth, membres, postes, accueil, invitations, collectes, reunions, depenses, decisions, calendrier, rapports, rotations, parametres, presences, pv, rapports_rsg, jetons, anniversaires, commandes_jetons, litterature, ventes_litterature, contributions, config_repartition, sondages, jumelages, propositions, operations_bancaires, evenements
from app.routers import auth, membres, postes, accueil, invitations, collectes, reunions, depenses, decisions, calendrier, rapports, rotations, parametres, presences, pv, rapports_rsg, jetons, anniversaires, commandes_jetons, litterature, ventes_litterature, contributions, config_repartition, sondages, propositions, operations_bancaires, evenements
# Import models pour que create_all crée toutes les tables
from app.models.sync_log import SyncLog # noqa: F401
@ -17,7 +17,8 @@ from app.models.proposition import Proposition # noqa: F401
from app.models.operation_bancaire import OperationBancaire # noqa: F401
from app.models.evenement import Evenement # noqa: F401
from app.models.candidature import Candidature # noqa: F401
from app.models.tache import Tache, PosteTache # noqa: F401
from app.models.poste_module import PosteModule # noqa: F401
from app.models.reserve import Reserve # noqa: F401
@asynccontextmanager
@ -68,7 +69,6 @@ app.include_router(ventes_litterature.router, prefix="/api")
app.include_router(contributions.router, prefix="/api")
app.include_router(config_repartition.router, prefix="/api")
app.include_router(sondages.router, prefix="/api")
app.include_router(jumelages.router, prefix="/api")
app.include_router(propositions.router, prefix="/api")
app.include_router(operations_bancaires.router, prefix="/api")
app.include_router(evenements.router, prefix="/api")

View file

@ -1,23 +0,0 @@
"""Table jumelages — Parrainage discret."""
import uuid
from datetime import date, datetime, timezone
from sqlalchemy import String, Date, DateTime, ForeignKey, Enum as SAEnum
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
class Jumelage(Base):
__tablename__ = "jumelages"
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
groupe_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("groupes.id"), nullable=False)
parrain_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("membres.id"), nullable=False)
filleul_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("membres.id"), nullable=False)
date_jumelage: Mapped[date] = mapped_column(Date, nullable=False)
statut: Mapped[str] = mapped_column(
SAEnum("actif", "termine", name="statut_jumelage"),
default="actif",
)
note: Mapped[str | None] = mapped_column(String(255))
cree_par: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("membres.id"))
cree_le: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))

View file

@ -0,0 +1,16 @@
"""Acces aux modules par poste."""
import uuid
from datetime import datetime, timezone
from sqlalchemy import DateTime, ForeignKey, String
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
class PosteModule(Base):
__tablename__ = "postes_modules"
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
poste_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("postes.id"), nullable=False)
module_code: Mapped[str] = mapped_column(String(20), nullable=False)
cree_le: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))

View file

@ -0,0 +1,19 @@
"""Table reserves — montants mis de cote par le groupe."""
import uuid
from datetime import datetime, timezone
from sqlalchemy import Boolean, DateTime, ForeignKey, Numeric, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
class Reserve(Base):
__tablename__ = "reserves"
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
groupe_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("groupes.id"), nullable=False)
nom: Mapped[str] = mapped_column(String(120), nullable=False)
montant: Mapped[float] = mapped_column(Numeric(10, 2), nullable=False)
note: Mapped[str | None] = mapped_column(Text)
active: Mapped[bool] = mapped_column(Boolean, default=True)
cree_le: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))

View file

@ -1,30 +0,0 @@
"""Table taches — tâches assignables aux postes."""
import uuid
from datetime import datetime, timezone
from sqlalchemy import String, Text, DateTime, ForeignKey, Integer
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
class Tache(Base):
__tablename__ = "taches"
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
nom: Mapped[str] = mapped_column(String(200), nullable=False)
informations: Mapped[str | None] = mapped_column(Text)
ordre_affichage: Mapped[int] = mapped_column(Integer, default=99)
cree_le: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
class PosteTache(Base):
"""Association poste ↔ tâche (N:N). Une tâche peut être affectée à plusieurs postes."""
__tablename__ = "postes_taches"
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
poste_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("postes.id"), nullable=False)
tache_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("taches.id"), nullable=False)
cree_le: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)

View file

@ -8,6 +8,7 @@ from app.core.security import get_current_membre
from app.models.affectation import Affectation
from app.models.poste import Poste
from app.models.groupe import Groupe
from app.models.poste_module import PosteModule
from app.schemas.schemas import AccueilResponse, ModuleTuile, MembrePublic
router = APIRouter(prefix="/accueil", tags=["Accueil"])
@ -15,16 +16,18 @@ router = APIRouter(prefix="/accueil", tags=["Accueil"])
# ── Définition des modules ──
MODULES_CONSULTATION = [
ModuleTuile(code="C1", nom="Rapports PDF", description="Finances, PV, résolutions", icone="📄", couleur="blue"),
ModuleTuile(code="C2", nom="Événements à venir", description="Anniversaires et événements", icone="📅", couleur="green"),
ModuleTuile(code="C2", nom="Événements", description="Anniversaires et événements", icone="📅", couleur="green"),
ModuleTuile(code="C4", nom="Littérature", description="Inventaire et prix", icone="📚", couleur="purple"),
ModuleTuile(code="C5", nom="Décisions en cours", description="Suivi réunion d'affaires", icone="📌", couleur="red"),
ModuleTuile(code="C6", nom="Votes ouverts", description="Conscience de groupe", icone="🗳️", couleur="indigo"),
ModuleTuile(code="C7", nom="Mon profil", description="Modifier, PIN, notifications", icone="👤", couleur="gray"),
ModuleTuile(code="C8", nom="Propositions", description="Soumettre, seconder, résolutions", icone="📋", couleur="orange"),
ModuleTuile(code="C9", nom="S'impliquer", description="Postes disponibles, candidature", icone="🙋", couleur="yellow"),
ModuleTuile(code="C9", nom="Postes à pourvoir", description="Postes disponibles, candidature", icone="🙋", couleur="yellow"),
ModuleTuile(code="C10", nom="État financier", description="Résultats et transactions", icone="📊", couleur="blue"),
]
MODULES_PAR_CODE = {m.code: m for m in MODULES_CONSULTATION}
# Modules de saisie visibles par TOUS les membres
MODULES_SAISIE_TOUS = [
ModuleTuile(code="S1", nom="Collecte 7e", description="Saisir le panier", icone="💰", couleur="green"),
@ -61,17 +64,17 @@ MODULES_SAISIE = {
}
MODULES_GESTION = [
ModuleTuile(code="G1", nom="Inviter un membre", description="Code d'invitation 48h", icone="➕", couleur="green"),
ModuleTuile(code="G2", nom="Gérer les membres", description="Fiche complète, désactiver", icone="👥", couleur="blue"),
ModuleTuile(code="G3", nom="Affecter les postes", description="Assigner un serviteur", icone="🎯", couleur="purple"),
ModuleTuile(code="G4", nom="Terminer un mandat", description="Transition manuelle", icone="🔄", couleur="orange"),
ModuleTuile(code="G5", nom="Planifier rotations", description="Calendrier serviteurs", icone="📅", couleur="green"),
ModuleTuile(code="G2", nom="Gestion des membres", description="Membres, invitations, désactivation", icone="👥", couleur="blue"),
ModuleTuile(code="G6", nom="Lancer un sondage", description="Conscience de groupe", icone="📊", couleur="indigo"),
ModuleTuile(code="G7", nom="Jumeler parrain", description="Parrain / filleul", icone="🤝", couleur="yellow"),
ModuleTuile(code="G9", nom="Paramètres groupe", description="Nom, horaire, adresse", icone="🔧", couleur="gray"),
ModuleTuile(code="G10", nom="Gérer les postes", description="Créer, abolir, candidatures", icone="📋", couleur="purple"),
ModuleTuile(code="G11", nom="Gestion des mandats", description="Affecter, terminer, rotations", icone="🎯", couleur="purple"),
ModuleTuile(code="G12", nom="Gestion des postes", description="Postes, candidatures, accès", icone="📋", couleur="purple"),
]
for _modules in [MODULES_SAISIE_TOUS, MODULES_GESTION, *MODULES_SAISIE.values()]:
for _m in _modules:
MODULES_PAR_CODE[_m.code] = _m
@router.get("/", response_model=AccueilResponse)
async def accueil(
@ -90,11 +93,13 @@ async def accueil(
postes_noms = []
postes_categories = []
poste_ids = []
for a in affectations:
poste = await db.get(Poste, a.poste_id)
if poste:
postes_noms.append(poste.nom)
postes_categories.append(poste.categorie)
poste_ids.append(poste.id)
# Modules de consultation : toujours visibles
modules_consultation = MODULES_CONSULTATION
@ -102,14 +107,32 @@ async def accueil(
# Modules de saisie : collecte pour tous + modules par poste
modules_saisie = list(MODULES_SAISIE_TOUS)
codes_vus = {"S1", "S5"}
for nom_poste in postes_noms:
for m in MODULES_SAISIE.get(nom_poste, []):
if m.code not in codes_vus:
result = await db.execute(select(PosteModule).where(PosteModule.poste_id.in_(poste_ids))) if poste_ids else None
modules_configures = result.scalars().all() if result else []
if modules_configures:
codes = sorted({pm.module_code for pm in modules_configures})
for code in codes:
m = MODULES_PAR_CODE.get(code)
if m and not code.startswith("G") and m.code not in codes_vus:
modules_saisie.append(m)
codes_vus.add(m.code)
else:
for nom_poste in postes_noms:
for m in MODULES_SAISIE.get(nom_poste, []):
if m.code not in codes_vus:
modules_saisie.append(m)
codes_vus.add(m.code)
# Modules de gestion : seulement si exécutif
modules_gestion = MODULES_GESTION if "executif" in postes_categories else []
if modules_configures:
modules_gestion = [
MODULES_PAR_CODE[code]
for code in sorted({pm.module_code for pm in modules_configures})
if code.startswith("G") and code in MODULES_PAR_CODE
]
else:
modules_gestion = MODULES_GESTION if "executif" in postes_categories else []
# Info groupe pour le frontend
groupe = await db.get(Groupe, membre.groupe_id)

View file

@ -12,6 +12,7 @@ from app.models.membre import Membre
from app.models.depense import Depense
from app.models.envoi_contribution import EnvoiContribution
from app.models.operation_bancaire import OperationBancaire
from app.models.reserve import Reserve
from app.models.affectation import Affectation
from app.models.poste import Poste
from app.schemas.phase1 import CollecteCreate, CollecteOut, CollecteUpdate, SoldesOut
@ -291,7 +292,28 @@ async def soldes_tresorerie(
result = await db.execute(q)
retraits_depuis = float(result.scalar())
solde_banque = solde_releve + depots_depuis - retraits_depuis
# Contributions débitées après relevé
q = select(func.coalesce(func.sum(EnvoiContribution.montant), 0)).where(
EnvoiContribution.groupe_id == gid,
EnvoiContribution.debitee == True,
)
if filtre_depuis:
q = q.where(EnvoiContribution.date_envoi > filtre_depuis)
result = await db.execute(q)
contributions_debitees_depuis = float(result.scalar())
# Dépenses bancaires débitées après relevé
q = select(func.coalesce(func.sum(Depense.montant), 0)).where(
Depense.groupe_id == gid,
Depense.paye == True,
Depense.methode.in_(["VIREMENT", "CHEQUE"]),
)
if filtre_depuis:
q = q.where(Depense.date_confirmation > filtre_depuis)
result = await db.execute(q)
depenses_bancaires_depuis = float(result.scalar())
solde_banque = solde_releve + depots_depuis - retraits_depuis - contributions_debitees_depuis - depenses_bancaires_depuis
# ── Dépenses engagées non payées (chèque/virement votés mais pas encore débités) ──
result = await db.execute(
@ -303,25 +325,35 @@ async def soldes_tresorerie(
)
engagements_non_debites = float(result.scalar())
# ── Contributions envoyées non débitées ──
# (on considère toutes les contributions comme engagées)
# ── Contributions engagées non débitées ──
result = await db.execute(
select(func.coalesce(func.sum(EnvoiContribution.montant), 0)).where(
EnvoiContribution.groupe_id == gid,
EnvoiContribution.engagee == True,
EnvoiContribution.debitee == False,
)
)
contributions_non_debitees = float(result.scalar())
engagements_non_debites += contributions_non_debitees
# ── Réserves ──
reserve_prudente = 500.0
# Réserve anniversaire : +20$/mois depuis la création du groupe
from app.models.groupe import Groupe as GroupeModel
groupe = await db.get(GroupeModel, gid)
mois_depuis_creation = 0
if groupe and groupe.cree_le:
from datetime import date as date_type
aujourdhui = date_type.today()
cree = groupe.cree_le.date() if hasattr(groupe.cree_le, 'date') else groupe.cree_le
mois_depuis_creation = (aujourdhui.year - cree.year) * 12 + (aujourdhui.month - cree.month)
mois_depuis_creation = max(0, mois_depuis_creation)
reserve_anniversaire = mois_depuis_creation * 20.0
total_reserves = reserve_prudente + reserve_anniversaire
result = await db.execute(
select(Reserve).where(
Reserve.groupe_id == gid,
Reserve.active == True,
).order_by(Reserve.nom)
)
reserves = result.scalars().all()
reserves_details = [
{
"id": str(r.id),
"nom": r.nom,
"montant": float(r.montant),
"note": r.note,
}
for r in reserves
]
total_reserves = sum(r["montant"] for r in reserves_details)
# ── Somme disponible ──
disponible = solde_banque - engagements_non_debites - total_reserves
@ -337,6 +369,11 @@ async def soldes_tresorerie(
OperationBancaire.groupe_id == gid, OperationBancaire.type == "depot"))
total_depots = float(result.scalar())
result = await db.execute(
select(func.coalesce(func.sum(OperationBancaire.montant), 0)).where(
OperationBancaire.groupe_id == gid, OperationBancaire.type == "retrait"))
total_retraits = float(result.scalar())
result = await db.execute(
select(func.coalesce(func.sum(Depense.montant), 0)).where(
Depense.groupe_id == gid, Depense.paye == True, Depense.methode == "COLLECTE"))
@ -348,7 +385,7 @@ async def soldes_tresorerie(
VenteLitterature.groupe_id == gid, VenteLitterature.remis_tresorier == True))
ventes_recues = float(result.scalar())
encaisse = total_collectes_recues + ventes_recues - total_depots - depenses_cash
encaisse = total_collectes_recues + ventes_recues + total_retraits - total_depots - depenses_cash
# ── Argent en circulation (collectes non remises au trésorier) ──
result = await db.execute(
@ -393,12 +430,13 @@ async def soldes_tresorerie(
return {
"solde_banque": round(solde_banque, 2),
"date_releve": date_releve,
"encaisse": round(max(0, encaisse), 2),
"encaisse": round(encaisse, 2),
"disponible": round(disponible, 2),
"engagements_non_debites": round(engagements_non_debites, 2),
"reserve_prudente": reserve_prudente,
"reserve_anniversaire": round(reserve_anniversaire, 2),
"reserve_prudente": 0,
"reserve_anniversaire": 0,
"total_reserves": round(total_reserves, 2),
"reserves_details": reserves_details,
"en_possession": round(total_en_circulation, 2),
"en_possession_details": details_circulation,
"ventes_non_remises": round(total_ventes_non_remises, 2),
@ -410,6 +448,7 @@ async def soldes_tresorerie(
async def registre_transactions(
mois: int | None = None,
annee: int | None = None,
type_transaction: str | None = None,
membre=Depends(get_current_membre),
db: AsyncSession = Depends(get_db),
):
@ -428,6 +467,11 @@ async def registre_transactions(
return d.year == annee
return True
def ajouter_transaction(transaction):
if type_transaction and transaction["type"] != type_transaction:
return
transactions.append(transaction)
# 1. Collectes reçues
result = await db.execute(
select(Collecte).where(Collecte.groupe_id == gid, Collecte.statut == "recu")
@ -436,7 +480,7 @@ async def registre_transactions(
for c in result.scalars().all():
if filtre_mois(c.date_collecte):
saisi = await db.get(Membre, c.saisi_par) if c.saisi_par else None
transactions.append({
ajouter_transaction({
"date": str(c.date_collecte),
"type": "collecte",
"description": f"Collecte 7e Tradition",
@ -455,7 +499,7 @@ async def registre_transactions(
for v in result.scalars().all():
if filtre_mois(v.date_vente):
item = await db.get(Litterature, v.litterature_id)
transactions.append({
ajouter_transaction({
"date": str(v.date_vente),
"type": "vente_litterature",
"description": f"Vente : {item.titre if item else '?'} (x{v.quantite})",
@ -472,7 +516,7 @@ async def registre_transactions(
for d in result.scalars().all():
if filtre_mois(d.date_depense):
qui = await db.get(Membre, d.qui)
transactions.append({
ajouter_transaction({
"date": str(d.date_depense),
"type": "depense",
"description": f"{d.quoi} ({d.methode})",
@ -483,12 +527,15 @@ async def registre_transactions(
# 4. Envois de contributions
result = await db.execute(
select(EnvoiContribution).where(EnvoiContribution.groupe_id == gid)
select(EnvoiContribution).where(
EnvoiContribution.groupe_id == gid,
EnvoiContribution.debitee == True,
)
.order_by(EnvoiContribution.date_envoi)
)
for e in result.scalars().all():
if filtre_mois(e.date_envoi):
transactions.append({
ajouter_transaction({
"date": str(e.date_envoi),
"type": "contribution",
"description": f"Contribution {e.destination} ({e.methode})",
@ -508,7 +555,7 @@ async def registre_transactions(
if filtre_mois(op.date_operation):
m = await db.get(Membre, op.effectue_par)
is_depot = op.type == "depot"
transactions.append({
ajouter_transaction({
"date": str(op.date_operation),
"type": f"banque_{op.type}",
"description": f"{'Dépôt' if is_depot else 'Retrait'} bancaire{(' — ' + op.note) if op.note else ''}",

View file

@ -2,7 +2,7 @@
from datetime import date
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc
from sqlalchemy import select, desc, func
from app.core.database import get_db
from app.core.security import get_current_membre
@ -28,6 +28,20 @@ DEFAUT_REPARTITION = {
}
async def _prochain_numero_resolution(groupe_id, annee: int, db: AsyncSession) -> str:
from app.models.proposition import Proposition
result = await db.execute(
select(func.count(Proposition.id)).where(
Proposition.groupe_id == groupe_id,
Proposition.numero_resolution.isnot(None),
Proposition.numero_resolution.like(f"RES-{annee}-%"),
)
)
count = result.scalar() or 0
return f"RES-{annee}-{count + 1:03d}"
async def require_tresorier(membre=Depends(get_current_membre), db: AsyncSession = Depends(get_db)):
result = await db.execute(
select(Affectation).join(Poste).where(
@ -92,30 +106,40 @@ async def enregistrer_envoi(
membre=Depends(require_tresorier),
db: AsyncSession = Depends(get_db),
):
"""M4 — Enregistrer un envoi de contribution. Exige une résolution adoptée."""
"""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}")
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:
raise HTTPException(status_code=400, detail="Le proposeur et le secondeur doivent être différents")
# Valider la résolution
if not req.resolution_id:
raise HTTPException(
status_code=400,
detail="Une contribution doit faire l'objet d'une résolution adoptée. Créez d'abord une proposition, faites-la seconder et adopter."
)
result = await db.execute(
select(Proposition).where(
Proposition.id == req.resolution_id,
Proposition.groupe_id == membre.groupe_id,
Proposition.statut == "adoptee",
)
proposeur = await db.get(Membre, req.proposeur_id)
secondeur = await db.get(Membre, req.secondeur_id)
if not proposeur or proposeur.groupe_id != membre.groupe_id or not proposeur.actif:
raise HTTPException(status_code=404, detail="Proposeur introuvable ou inactif")
if not secondeur or secondeur.groupe_id != membre.groupe_id or not secondeur.actif:
raise HTTPException(status_code=404, detail="Secondeur introuvable ou inactif")
numero_resolution = await _prochain_numero_resolution(membre.groupe_id, date.today().year, db)
resolution = Proposition(
groupe_id=membre.groupe_id,
type_proposition="contribution",
sujet=f"Envoi de {req.montant:.2f} $ à {DESTINATIONS_LABELS[req.destination]}",
description=(
f"Contribution enregistrée le {req.date_envoi} par le module Envoi contributions. "
f"Méthode prévue : {req.methode.upper()}."
),
proposeur_id=req.proposeur_id,
secondeur_id=req.secondeur_id,
statut="adoptee",
numero_resolution=numero_resolution,
date_vote=date.today(),
)
resolution = result.scalar_one_or_none()
if not resolution:
raise HTTPException(status_code=400, detail="Résolution introuvable ou non adoptée")
db.add(resolution)
await db.flush()
envoi = EnvoiContribution(
groupe_id=membre.groupe_id,
@ -123,8 +147,9 @@ async def enregistrer_envoi(
destination=req.destination,
montant=req.montant,
methode=req.methode.upper(),
resolution_id=req.resolution_id,
resolution_id=resolution.id,
engagee=True,
debitee=False,
saisi_par=membre.id,
)
db.add(envoi)
@ -145,6 +170,54 @@ async def enregistrer_envoi(
)
@router.post("/{envoi_id}/confirmer-debit", response_model=EnvoiContributionOut)
async def confirmer_debit_contribution(
envoi_id: str,
membre=Depends(require_tresorier),
db: AsyncSession = Depends(get_db),
):
"""Le trésorier confirme que la contribution a été débitée du compte."""
result = await db.execute(
select(EnvoiContribution).where(
EnvoiContribution.id == envoi_id,
EnvoiContribution.groupe_id == membre.groupe_id,
)
)
envoi = result.scalar_one_or_none()
if not envoi:
raise HTTPException(status_code=404, detail="Envoi introuvable")
if envoi.debitee:
raise HTTPException(status_code=400, detail="Contribution déjà débitée")
from app.routers.operations_bancaires import _solde_banque_brut, _total_reserves
solde_banque = await _solde_banque_brut(membre.groupe_id, db)
if float(envoi.montant) > solde_banque:
raise HTTPException(status_code=400, detail="Le solde en banque est insuffisant pour confirmer ce débit")
total_reserves = await _total_reserves(membre.groupe_id, db)
if solde_banque - float(envoi.montant) < total_reserves:
raise HTTPException(status_code=400, detail="Ce débit ferait dépasser les réserves par rapport au solde en banque")
envoi.debitee = True
envoi.engagee = True
await db.flush()
saisi = await db.get(Membre, envoi.saisi_par) if envoi.saisi_par else None
return EnvoiContributionOut(
id=envoi.id,
date_envoi=envoi.date_envoi,
destination=envoi.destination,
destination_label=DESTINATIONS_LABELS.get(envoi.destination, envoi.destination),
montant=float(envoi.montant),
methode=envoi.methode,
resolution_id=envoi.resolution_id,
engagee=envoi.engagee,
debitee=envoi.debitee,
saisi_par_prenom=saisi.prenom if saisi else None,
cree_le=envoi.cree_le,
)
@router.get("/", response_model=list[EnvoiContributionOut])
async def liste_envois(
limite: int = 30,

View file

@ -114,7 +114,8 @@ async def confirmer_depense(
db: AsyncSession = Depends(get_db),
):
"""Le trésorier confirme la dépense et renseigne le mode de paiement."""
if methode.upper() not in METHODES_VALIDES:
methode_norm = methode.upper()
if methode_norm not in METHODES_VALIDES:
raise HTTPException(status_code=400, detail=f"Méthode invalide. Choix : {', '.join(METHODES_VALIDES)}")
result = await db.execute(
@ -124,12 +125,19 @@ async def confirmer_depense(
if not depense:
raise HTTPException(status_code=404, detail="Dépense introuvable")
depense.methode = methode.upper()
if methode_norm == "COLLECTE":
from app.routers.operations_bancaires import _encaisse
encaisse = await _encaisse(membre.groupe_id, db)
if float(depense.montant) > encaisse:
raise HTTPException(status_code=400, detail="L'encaisse disponible est insuffisante pour payer cette dépense")
depense.methode = methode_norm
depense.confirme_par = membre.id
depense.date_confirmation = datetime.now(timezone.utc)
# Si cash (COLLECTE) → payé immédiatement
if methode.upper() == "COLLECTE":
if methode_norm == "COLLECTE":
depense.paye = True
# Si DU → le groupe doit rembourser, pas encore payé
@ -157,6 +165,15 @@ async def confirmer_debit(
if depense.methode not in ("CHEQUE", "VIREMENT"):
raise HTTPException(status_code=400, detail="Seuls les chèques/virements nécessitent confirmation de débit")
from app.routers.operations_bancaires import _solde_banque_brut, _total_reserves
solde_banque = await _solde_banque_brut(membre.groupe_id, db)
if float(depense.montant) > solde_banque:
raise HTTPException(status_code=400, detail="Le solde en banque est insuffisant pour confirmer ce débit")
total_reserves = await _total_reserves(membre.groupe_id, db)
if solde_banque - float(depense.montant) < total_reserves:
raise HTTPException(status_code=400, detail="Ce débit ferait dépasser les réserves par rapport au solde en banque")
depense.paye = True
await db.flush()
return await _to_out(depense, db)

View file

@ -1,4 +1,4 @@
"""Routes — Événements à venir (anniversaires + ad hoc + résolutions)."""
"""Routes — Événements (anniversaires + ad hoc + résolutions)."""
from datetime import date, timedelta
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
@ -13,33 +13,27 @@ from app.models.poste import Poste
from app.models.evenement import Evenement
from app.models.proposition import Proposition
router = APIRouter(prefix="/evenements", tags=["Événements à venir"])
router = APIRouter(prefix="/evenements", tags=["Événements"])
def _prochain_anniversaire(date_abstinence: date, aujourdhui: date) -> tuple[int, date, int]:
"""Retourne (années, prochaine date, jours restants)."""
def _anniversaire_sobriete(date_abstinence: date, annee: int) -> tuple[int, date]:
try:
anniv = date_abstinence.replace(year=aujourdhui.year)
anniv = date_abstinence.replace(year=annee)
except ValueError:
anniv = date(aujourdhui.year, 3, 1)
if anniv < aujourdhui:
try:
anniv = date_abstinence.replace(year=aujourdhui.year + 1)
except ValueError:
anniv = date(aujourdhui.year + 1, 3, 1)
annees = anniv.year - date_abstinence.year
jours = (anniv - aujourdhui).days
return max(0, annees), anniv, jours
anniv = date(annee, 3, 1)
return max(0, anniv.year - date_abstinence.year), anniv
@router.get("/")
async def evenements_a_venir(
jours: int = 90,
jours_avant: int = 365,
membre=Depends(get_current_membre),
db: AsyncSession = Depends(get_db),
):
"""Tous les événements à venir : réunions, anniversaires, ad hoc."""
"""Tous les événements : anniversaires, ad hoc et résolutions."""
aujourdhui = date.today()
debut = aujourdhui - timedelta(days=jours_avant)
limite = aujourdhui + timedelta(days=jours)
groupe = await db.get(Groupe, membre.groupe_id)
district = groupe.district if groupe else "87-16"
@ -57,11 +51,13 @@ async def evenements_a_venir(
)
)
for m, groupe_nom in result.all():
annees, prochain, jours_restants = _prochain_anniversaire(m.date_abstinence, aujourdhui)
if 0 <= jours_restants <= jours:
for annee in range(debut.year, limite.year + 1):
annees, anniv = _anniversaire_sobriete(m.date_abstinence, annee)
if anniv < debut or anniv > limite:
continue
evenements.append({
"type": "anniversaire",
"date": str(prochain),
"date": str(anniv),
"titre": f"🎂 {m.prenom} — {annees} an{'s' if annees > 1 else ''}",
"detail": f"Anniversaire de sobriété · {groupe_nom}",
"lieu": None,
@ -74,7 +70,7 @@ async def evenements_a_venir(
.join(Groupe, Evenement.groupe_id == Groupe.id)
.where(
Groupe.district == district,
Evenement.date_evenement >= aujourdhui,
Evenement.date_evenement >= debut,
Evenement.date_evenement <= limite,
)
)

View file

@ -18,7 +18,7 @@ async def creer_invitation(
executif=Depends(require_executif),
db: AsyncSession = Depends(get_db),
):
"""G1 — Générer un code d'invitation (expire après 48h)."""
"""Générer un code d'invitation (expire après 48h)."""
code = secrets.token_hex(6).upper() # 12 caractères hex
invitation = Invitation(
groupe_id=executif.groupe_id,

View file

@ -1,99 +0,0 @@
"""Routes G7 — Jumelage parrain/filleul (confidentiel)."""
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc
from app.core.database import get_db
from app.core.security import require_executif
from app.models.jumelage import Jumelage
from app.models.membre import Membre
from app.schemas.phase8 import JumelageCreate, JumelageOut, JumelageUpdate
router = APIRouter(prefix="/jumelages", tags=["G7 — Parrainage"])
async def _to_out(j: Jumelage, db: AsyncSession) -> JumelageOut:
parrain = await db.get(Membre, j.parrain_id)
filleul = await db.get(Membre, j.filleul_id)
cree = await db.get(Membre, j.cree_par) if j.cree_par else None
return JumelageOut(
id=j.id,
parrain_prenom=parrain.prenom if parrain else "?",
filleul_prenom=filleul.prenom if filleul else "?",
date_jumelage=j.date_jumelage,
statut=j.statut,
note=j.note,
cree_par_prenom=cree.prenom if cree else None,
)
@router.post("/", response_model=JumelageOut)
async def creer_jumelage(
req: JumelageCreate,
membre=Depends(require_executif),
db: AsyncSession = Depends(get_db),
):
"""G7 — Jumeler un parrain et un filleul."""
if req.parrain_id == req.filleul_id:
raise HTTPException(status_code=400, detail="Le parrain et le filleul doivent être différents")
parrain = await db.get(Membre, req.parrain_id)
filleul = await db.get(Membre, req.filleul_id)
if not parrain or not parrain.actif:
raise HTTPException(status_code=404, detail="Parrain introuvable ou inactif")
if not filleul or not filleul.actif:
raise HTTPException(status_code=404, detail="Filleul introuvable ou inactif")
jumelage = Jumelage(
groupe_id=membre.groupe_id,
parrain_id=req.parrain_id,
filleul_id=req.filleul_id,
date_jumelage=req.date_jumelage,
note=req.note,
cree_par=membre.id,
)
db.add(jumelage)
await db.flush()
return await _to_out(jumelage, db)
@router.get("/", response_model=list[JumelageOut])
async def liste_jumelages(
statut: str | None = None,
membre=Depends(require_executif),
db: AsyncSession = Depends(get_db),
):
"""Liste des jumelages (confidentiel — exécutif seulement)."""
query = select(Jumelage).where(Jumelage.groupe_id == membre.groupe_id)
if statut:
query = query.where(Jumelage.statut == statut)
query = query.order_by(desc(Jumelage.date_jumelage))
result = await db.execute(query)
jumelages = result.scalars().all()
return [await _to_out(j, db) for j in jumelages]
@router.patch("/{jumelage_id}", response_model=JumelageOut)
async def modifier_jumelage(
jumelage_id: str,
req: JumelageUpdate,
membre=Depends(require_executif),
db: AsyncSession = Depends(get_db),
):
"""Modifier ou terminer un jumelage."""
result = await db.execute(
select(Jumelage).where(Jumelage.id == jumelage_id, Jumelage.groupe_id == membre.groupe_id)
)
jumelage = result.scalar_one_or_none()
if not jumelage:
raise HTTPException(status_code=404, detail="Jumelage introuvable")
if req.statut and req.statut in ("actif", "termine"):
jumelage.statut = req.statut
if req.note is not None:
jumelage.note = req.note
await db.flush()
return await _to_out(jumelage, db)

View file

@ -1,7 +1,7 @@
"""Routes membres — profil et gestion."""
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
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
@ -25,13 +25,32 @@ async def modifier_profil(
):
"""C7 — Modifier mon profil."""
if updates.prenom is not None:
membre.prenom = updates.prenom.strip().title()
prenom = updates.prenom.strip()
if not prenom:
raise HTTPException(status_code=400, detail="Le prénom est obligatoire")
membre.prenom = prenom.title()
if updates.nom is not None:
membre.nom = updates.nom.strip().title() if updates.nom.strip() else None
if updates.telephone is not None:
membre.telephone = updates.telephone.strip()
telephone = updates.telephone.strip()
if not telephone:
raise HTTPException(status_code=400, detail="Le téléphone est obligatoire")
membre.telephone = telephone
if updates.courriel is not None:
membre.courriel = updates.courriel.strip().lower()
courriel = updates.courriel.strip().lower()
if not courriel or "@" not in courriel:
raise HTTPException(status_code=400, detail="Courriel invalide")
result = await db.execute(
select(Membre).where(
func.lower(Membre.courriel) == courriel,
Membre.groupe_id == membre.groupe_id,
Membre.id != membre.id,
Membre.actif == True,
)
)
if result.scalar_one_or_none():
raise HTTPException(status_code=400, detail="Ce courriel est déjà utilisé dans ce groupe")
membre.courriel = courriel
if updates.date_abstinence is not None:
membre.date_abstinence = updates.date_abstinence
await db.flush()

View file

@ -1,15 +1,19 @@
"""Routes — Opérations bancaires (dépôts, retraits)."""
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc
from sqlalchemy import select, desc, func
from app.core.database import get_db
from app.core.security import get_current_membre
from app.models.operation_bancaire import OperationBancaire
from app.models.collecte import Collecte
from app.models.depense import Depense
from app.models.reserve import Reserve
from app.models.vente_litterature import VenteLitterature
from app.models.membre import Membre
from app.models.affectation import Affectation
from app.models.poste import Poste
from app.schemas.phase1 import OperationBancaireCreate, OperationBancaireOut
from app.schemas.phase1 import OperationBancaireCreate, OperationBancaireOut, ReserveCreate, ReserveOut, ReserveUpdate
router = APIRouter(prefix="/banque", tags=["Opérations bancaires"])
@ -28,6 +32,77 @@ async def _require_tresorier(membre=Depends(get_current_membre), db: AsyncSessio
return membre
async def _encaisse(groupe_id, db: AsyncSession) -> float:
total_collectes = float((await db.execute(
select(func.coalesce(func.sum(Collecte.montant), 0)).where(
Collecte.groupe_id == groupe_id, Collecte.statut == "recu")
)).scalar())
ventes_recues = float((await db.execute(
select(func.coalesce(func.sum(VenteLitterature.montant), 0)).where(
VenteLitterature.groupe_id == groupe_id, VenteLitterature.remis_tresorier == True)
)).scalar())
retraits = float((await db.execute(
select(func.coalesce(func.sum(OperationBancaire.montant), 0)).where(
OperationBancaire.groupe_id == groupe_id, OperationBancaire.type == "retrait")
)).scalar())
depots = float((await db.execute(
select(func.coalesce(func.sum(OperationBancaire.montant), 0)).where(
OperationBancaire.groupe_id == groupe_id, OperationBancaire.type == "depot")
)).scalar())
depenses_cash = float((await db.execute(
select(func.coalesce(func.sum(Depense.montant), 0)).where(
Depense.groupe_id == groupe_id, Depense.paye == True, Depense.methode == "COLLECTE")
)).scalar())
return total_collectes + ventes_recues + retraits - depots - depenses_cash
async def _solde_banque_brut(groupe_id, db: AsyncSession) -> float:
from app.models.envoi_contribution import EnvoiContribution
result = await db.execute(
select(OperationBancaire).where(
OperationBancaire.groupe_id == groupe_id,
OperationBancaire.type == "releve",
).order_by(desc(OperationBancaire.date_operation)).limit(1)
)
dernier_releve = result.scalar_one_or_none()
solde_releve = float(dernier_releve.montant) if dernier_releve else 0.0
filtre_depuis = dernier_releve.date_operation if dernier_releve else None
q_depots = select(func.coalesce(func.sum(OperationBancaire.montant), 0)).where(
OperationBancaire.groupe_id == groupe_id, OperationBancaire.type == "depot")
q_retraits = select(func.coalesce(func.sum(OperationBancaire.montant), 0)).where(
OperationBancaire.groupe_id == groupe_id, OperationBancaire.type == "retrait")
q_contribs = select(func.coalesce(func.sum(EnvoiContribution.montant), 0)).where(
EnvoiContribution.groupe_id == groupe_id, EnvoiContribution.debitee == True)
q_depenses = select(func.coalesce(func.sum(Depense.montant), 0)).where(
Depense.groupe_id == groupe_id,
Depense.paye == True,
Depense.methode.in_(["VIREMENT", "CHEQUE"]),
)
if filtre_depuis:
q_depots = q_depots.where(OperationBancaire.date_operation > filtre_depuis)
q_retraits = q_retraits.where(OperationBancaire.date_operation > filtre_depuis)
q_contribs = q_contribs.where(EnvoiContribution.date_envoi > filtre_depuis)
q_depenses = q_depenses.where(Depense.date_confirmation > filtre_depuis)
depots = float((await db.execute(q_depots)).scalar())
retraits = float((await db.execute(q_retraits)).scalar())
contribs = float((await db.execute(q_contribs)).scalar())
depenses = float((await db.execute(q_depenses)).scalar())
return solde_releve + depots - retraits - contribs - depenses
async def _total_reserves(groupe_id, db: AsyncSession, exclure_id: str | None = None) -> float:
query = select(func.coalesce(func.sum(Reserve.montant), 0)).where(
Reserve.groupe_id == groupe_id,
Reserve.active == True,
)
if exclure_id:
query = query.where(Reserve.id != exclure_id)
return float((await db.execute(query)).scalar())
@router.post("/", response_model=OperationBancaireOut)
async def enregistrer_operation(
req: OperationBancaireCreate,
@ -39,6 +114,16 @@ async def enregistrer_operation(
raise HTTPException(status_code=400, detail="Type doit être 'depot', 'retrait' ou 'releve'")
if req.montant <= 0:
raise HTTPException(status_code=400, detail="Le montant doit être positif")
solde_banque = await _solde_banque_brut(membre.groupe_id, db)
total_reserves = await _total_reserves(membre.groupe_id, db)
if req.type == "depot" and req.montant > await _encaisse(membre.groupe_id, db):
raise HTTPException(status_code=400, detail="Le dépôt ne peut pas dépasser l'encaisse disponible")
if req.type == "retrait" and req.montant > solde_banque:
raise HTTPException(status_code=400, detail="Le retrait ne peut pas dépasser le solde en banque")
if req.type == "retrait" and solde_banque - req.montant < total_reserves:
raise HTTPException(status_code=400, detail="Le retrait ferait dépasser les réserves par rapport au solde en banque")
if req.type == "releve" and req.montant < total_reserves:
raise HTTPException(status_code=400, detail="Le relevé ne peut pas être inférieur au total des réserves")
op = OperationBancaire(
groupe_id=membre.groupe_id,
@ -61,6 +146,67 @@ async def enregistrer_operation(
)
@router.get("/reserves", response_model=list[ReserveOut])
async def liste_reserves(membre=Depends(get_current_membre), db: AsyncSession = Depends(get_db)):
result = await db.execute(
select(Reserve).where(Reserve.groupe_id == membre.groupe_id, Reserve.active == True)
.order_by(Reserve.nom)
)
return [
ReserveOut(id=r.id, nom=r.nom, montant=float(r.montant), note=r.note, active=r.active, cree_le=r.cree_le)
for r in result.scalars().all()
]
@router.post("/reserves", response_model=ReserveOut)
async def creer_reserve(req: ReserveCreate, membre=Depends(_require_tresorier), db: AsyncSession = Depends(get_db)):
if not req.nom.strip():
raise HTTPException(status_code=400, detail="Nom requis")
if req.montant < 0:
raise HTTPException(status_code=400, detail="Le montant doit être positif ou nul")
total = await _total_reserves(membre.groupe_id, db) + req.montant
solde_banque = await _solde_banque_brut(membre.groupe_id, db)
if total > solde_banque:
raise HTTPException(status_code=400, detail="Le total des réserves ne peut pas dépasser le solde en banque")
reserve = Reserve(groupe_id=membre.groupe_id, nom=req.nom.strip(), montant=req.montant, note=req.note)
db.add(reserve)
await db.flush()
return ReserveOut(id=reserve.id, nom=reserve.nom, montant=float(reserve.montant), note=reserve.note, active=reserve.active, cree_le=reserve.cree_le)
@router.patch("/reserves/{reserve_id}", response_model=ReserveOut)
async def modifier_reserve(reserve_id: str, req: ReserveUpdate, membre=Depends(_require_tresorier), db: AsyncSession = Depends(get_db)):
reserve = await db.get(Reserve, reserve_id)
if not reserve or reserve.groupe_id != membre.groupe_id or not reserve.active:
raise HTTPException(status_code=404, detail="Réserve introuvable")
montant = float(reserve.montant) if req.montant is None else req.montant
if montant < 0:
raise HTTPException(status_code=400, detail="Le montant doit être positif ou nul")
total = await _total_reserves(membre.groupe_id, db, reserve_id) + montant
solde_banque = await _solde_banque_brut(membre.groupe_id, db)
if total > solde_banque:
raise HTTPException(status_code=400, detail="Le total des réserves ne peut pas dépasser le solde en banque")
if req.nom is not None:
if not req.nom.strip():
raise HTTPException(status_code=400, detail="Nom requis")
reserve.nom = req.nom.strip()
reserve.montant = montant
if req.note is not None:
reserve.note = req.note.strip() or None
await db.flush()
return ReserveOut(id=reserve.id, nom=reserve.nom, montant=float(reserve.montant), note=reserve.note, active=reserve.active, cree_le=reserve.cree_le)
@router.delete("/reserves/{reserve_id}")
async def supprimer_reserve(reserve_id: str, membre=Depends(_require_tresorier), db: AsyncSession = Depends(get_db)):
reserve = await db.get(Reserve, reserve_id)
if not reserve or reserve.groupe_id != membre.groupe_id or not reserve.active:
raise HTTPException(status_code=404, detail="Réserve introuvable")
reserve.active = False
await db.flush()
return {"message": "Réserve supprimée"}
@router.get("/", response_model=list[OperationBancaireOut])
async def liste_operations(
limite: int = 20,

View file

@ -1,8 +1,8 @@
"""Routes postes, affectations, candidatures, tâches."""
"""Routes postes, affectations, candidatures et accès aux modules."""
from datetime import date
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from sqlalchemy import select, func
from app.core.database import get_db
from app.core.security import get_current_membre, require_executif
@ -10,12 +10,26 @@ from app.models.poste import Poste
from app.models.affectation import Affectation
from app.models.membre import Membre
from app.models.candidature import Candidature
from app.models.tache import Tache, PosteTache
from app.schemas.schemas import PosteOut, PosteCreate, AffectationCreate, TerminerMandatRequest, TacheCreate, TacheOut
from app.models.poste_module import PosteModule
from app.schemas.schemas import PosteOut, PosteCreate, AffectationCreate, TerminerMandatRequest
router = APIRouter(prefix="/postes", tags=["Postes et affectations"])
async def _prochain_numero_resolution(groupe_id, annee: int, db: AsyncSession) -> str:
from app.models.proposition import Proposition
result = await db.execute(
select(func.count(Proposition.id)).where(
Proposition.groupe_id == groupe_id,
Proposition.numero_resolution.isnot(None),
Proposition.numero_resolution.like(f"RES-{annee}-%"),
)
)
count = result.scalar() or 0
return f"RES-{annee}-{count + 1:03d}"
# ── Postes ──
@router.get("/", response_model=list[PosteOut])
@ -30,32 +44,7 @@ async def liste_postes(
result = await db.execute(query.order_by(Poste.ordre_affichage))
postes = result.scalars().all()
out = []
for p in postes:
# Charger les tâches associées
result_t = await db.execute(
select(Tache).join(PosteTache).where(PosteTache.poste_id == p.id)
.order_by(Tache.ordre_affichage)
)
taches_list = result_t.scalars().all()
taches = [{"id": str(t.id), "nom": t.nom, "informations": t.informations}
for t in taches_list]
# Description composite : description du poste + tâches associées
desc_parts = []
if p.description:
desc_parts.append(p.description)
if taches_list:
desc_parts.append("Tâches : " + ", ".join(
f"{t.nom}" + (f" ({t.informations})" if t.informations else "")
for t in taches_list
))
po = PosteOut.model_validate(p)
po.description = " — ".join(desc_parts) if desc_parts else p.description
po.taches = taches
out.append(po)
return out
return [PosteOut.model_validate(p) for p in postes]
@router.post("/", response_model=PosteOut)
@ -157,26 +146,11 @@ async def postes_disponibles(
)
deja_postule = result_cand.scalars().first() is not None
# Description composite avec tâches
result_t = await db.execute(
select(Tache).join(PosteTache).where(PosteTache.poste_id == p.id)
.order_by(Tache.ordre_affichage)
)
taches_list = result_t.scalars().all()
desc_parts = []
if p.description:
desc_parts.append(p.description)
if taches_list:
desc_parts.append("Tâches : " + ", ".join(
f"{t.nom}" + (f" ({t.informations})" if t.informations else "")
for t in taches_list
))
disponibles.append({
"id": str(p.id),
"nom": p.nom,
"categorie": p.categorie,
"description": " — ".join(desc_parts) if desc_parts else p.description,
"description": p.description,
"prerequis": p.prerequis,
"deja_postule": deja_postule,
})
@ -499,6 +473,49 @@ async def affectations_groupe(executif=Depends(require_executif), db: AsyncSessi
@router.post("/affectations")
async def creer_affectation(req: AffectationCreate, executif=Depends(require_executif), db: AsyncSession = Depends(get_db)):
from app.models.proposition import Proposition
if req.proposeur_id == req.secondeur_id:
raise HTTPException(status_code=400, detail="Le proposeur et le secondeur doivent être différents")
cible = await db.get(Membre, req.membre_id)
proposeur = await db.get(Membre, req.proposeur_id)
secondeur = await db.get(Membre, req.secondeur_id)
poste = await db.get(Poste, req.poste_id)
if not cible or cible.groupe_id != executif.groupe_id or not cible.actif:
raise HTTPException(status_code=404, detail="Membre à affecter introuvable ou inactif")
if not proposeur or proposeur.groupe_id != executif.groupe_id or not proposeur.actif:
raise HTTPException(status_code=404, detail="Proposeur introuvable ou inactif")
if not secondeur or secondeur.groupe_id != executif.groupe_id or not secondeur.actif:
raise HTTPException(status_code=404, detail="Secondeur introuvable ou inactif")
if not poste or not poste.actif:
raise HTTPException(status_code=404, detail="Poste introuvable ou aboli")
result = await db.execute(
select(Affectation).where(
Affectation.groupe_id == executif.groupe_id,
Affectation.poste_id == req.poste_id,
Affectation.statut == "actif",
)
)
if result.scalar_one_or_none():
raise HTTPException(status_code=400, detail="Ce poste est déjà affecté")
numero_resolution = await _prochain_numero_resolution(executif.groupe_id, req.date_debut.year, db)
resolution = Proposition(
groupe_id=executif.groupe_id,
type_proposition="nomination",
sujet=f"Nomination de {cible.prenom} au poste de {poste.nom}",
description=f"Affectation directe au poste de {poste.nom} à partir du {req.date_debut}.",
proposeur_id=req.proposeur_id,
secondeur_id=req.secondeur_id,
statut="adoptee",
numero_resolution=numero_resolution,
date_vote=req.date_debut,
)
db.add(resolution)
await db.flush()
affectation = Affectation(
groupe_id=executif.groupe_id,
membre_id=req.membre_id,
@ -508,7 +525,12 @@ async def creer_affectation(req: AffectationCreate, executif=Depends(require_exe
)
db.add(affectation)
await db.flush()
return {"message": "Affectation créée", "id": str(affectation.id)}
return {
"message": "Affectation créée",
"id": str(affectation.id),
"resolution_id": str(resolution.id),
"numero_resolution": resolution.numero_resolution,
}
@router.post("/affectations/{affectation_id}/terminer")
@ -566,133 +588,41 @@ async def supprimer_poste_permanent(
if poste.actif:
raise HTTPException(status_code=400, detail="Abolissez d'abord le poste avant de le supprimer")
# Supprimer les liens tâches
result = await db.execute(select(PosteTache).where(PosteTache.poste_id == poste.id))
for pt in result.scalars().all():
await db.delete(pt)
await db.delete(poste)
await db.flush()
return {"message": f"Poste «{poste.nom}» supprimé définitivement"}
# ── Tâches ──
# ── Acces aux modules ──
@router.get("/taches", response_model=list[TacheOut])
async def liste_taches(
_=Depends(get_current_membre),
db: AsyncSession = Depends(get_db),
):
"""Lister toutes les tâches avec les postes associés."""
result = await db.execute(select(Tache).order_by(Tache.ordre_affichage))
taches = result.scalars().all()
out = []
for t in taches:
result_p = await db.execute(
select(Poste.nom).join(PosteTache).where(PosteTache.tache_id == t.id)
)
postes_noms = [row[0] for row in result_p.all()]
out.append(TacheOut(
id=t.id, nom=t.nom, informations=t.informations, postes=postes_noms
))
return out
@router.post("/taches", response_model=TacheOut)
async def creer_tache(
req: TacheCreate,
@router.get("/modules")
async def modules_par_poste(
executif=Depends(require_executif),
db: AsyncSession = Depends(get_db),
):
"""Créer une tâche (exécutif)."""
tache = Tache(nom=req.nom.strip(), informations=req.informations)
db.add(tache)
await db.flush()
return TacheOut(id=tache.id, nom=tache.nom, informations=tache.informations)
result = await db.execute(select(PosteModule))
modules = result.scalars().all()
par_poste: dict[str, list[str]] = {}
for m in modules:
par_poste.setdefault(str(m.poste_id), []).append(m.module_code)
return par_poste
@router.patch("/taches/{tache_id}")
async def modifier_tache(
tache_id: str,
nom: str | None = None,
informations: str | None = None,
executif=Depends(require_executif),
db: AsyncSession = Depends(get_db),
):
"""Modifier une tâche."""
tache = await db.get(Tache, tache_id)
if not tache:
raise HTTPException(status_code=404, detail="Tâche introuvable")
if nom is not None:
tache.nom = nom.strip()
if informations is not None:
tache.informations = informations.strip() or None
await db.flush()
return {"message": "Tâche modifiée"}
@router.delete("/taches/{tache_id}")
async def supprimer_tache(
tache_id: str,
executif=Depends(require_executif),
db: AsyncSession = Depends(get_db),
):
"""Supprimer une tâche et ses associations."""
tache = await db.get(Tache, tache_id)
if not tache:
raise HTTPException(status_code=404, detail="Tâche introuvable")
result = await db.execute(select(PosteTache).where(PosteTache.tache_id == tache.id))
for pt in result.scalars().all():
await db.delete(pt)
await db.delete(tache)
await db.flush()
return {"message": f"Tâche «{tache.nom}» supprimée"}
# ── Affectation tâche ↔ poste ──
@router.post("/{poste_id}/taches/{tache_id}")
async def affecter_tache(
@router.put("/{poste_id}/modules")
async def definir_modules_poste(
poste_id: str,
tache_id: str,
codes: list[str],
executif=Depends(require_executif),
db: AsyncSession = Depends(get_db),
):
"""Affecter une tâche à un poste. Une tâche peut être affectée à plusieurs postes."""
poste = await db.get(Poste, poste_id)
tache = await db.get(Tache, tache_id)
if not poste or not poste.actif:
raise HTTPException(status_code=404, detail="Poste introuvable ou aboli")
if not tache:
raise HTTPException(status_code=404, detail="Tâche introuvable")
# Vérifier doublon
result = await db.execute(
select(PosteTache).where(PosteTache.poste_id == poste_id, PosteTache.tache_id == tache_id)
)
if result.scalars().first():
raise HTTPException(status_code=400, detail="Cette tâche est déjà affectée à ce poste")
pt = PosteTache(poste_id=poste_id, tache_id=tache_id)
db.add(pt)
result = await db.execute(select(PosteModule).where(PosteModule.poste_id == poste.id))
for item in result.scalars().all():
await db.delete(item)
for code in sorted(set(codes)):
db.add(PosteModule(poste_id=poste.id, module_code=code))
await db.flush()
return {"message": f"Tâche «{tache.nom}» affectée au poste «{poste.nom}»"}
@router.delete("/{poste_id}/taches/{tache_id}")
async def desaffecter_tache(
poste_id: str,
tache_id: str,
executif=Depends(require_executif),
db: AsyncSession = Depends(get_db),
):
"""Retirer une tâche d'un poste."""
result = await db.execute(
select(PosteTache).where(PosteTache.poste_id == poste_id, PosteTache.tache_id == tache_id)
)
pt = result.scalars().first()
if not pt:
raise HTTPException(status_code=404, detail="Association introuvable")
await db.delete(pt)
await db.flush()
return {"message": "Tâche retirée du poste"}
return {"message": "Accès aux modules mis à jour"}

View file

@ -220,7 +220,10 @@ async def rapports_disponibles(
select(
extract("year", EnvoiContribution.date_envoi).label("annee"),
extract("month", EnvoiContribution.date_envoi).label("mois"),
).where(EnvoiContribution.groupe_id == membre.groupe_id)
).where(
EnvoiContribution.groupe_id == membre.groupe_id,
EnvoiContribution.debitee == True,
)
.group_by("annee", "mois")
)
mois_contributions = [{"annee": int(r.annee), "mois": int(r.mois)} for r in result.all()]
@ -301,6 +304,7 @@ async def rapport_financier_complet(
result = await db.execute(
select(EnvoiContribution).where(
EnvoiContribution.groupe_id == gid,
EnvoiContribution.debitee == True,
extract("year", EnvoiContribution.date_envoi) == annee,
extract("month", EnvoiContribution.date_envoi) == mois,
)
@ -401,6 +405,7 @@ async def etat_financier_temps_reel(
"""État des résultats et transactions en temps réel (JSON)."""
from sqlalchemy import func, desc
from app.models.operation_bancaire import OperationBancaire
from app.models.reserve import Reserve
from app.models.vente_litterature import VenteLitterature
from app.models.litterature import Litterature
@ -444,7 +449,9 @@ async def etat_financier_temps_reel(
# ── Contributions ──
result = await db.execute(
select(func.coalesce(func.sum(EnvoiContribution.montant), 0)).where(
EnvoiContribution.groupe_id == gid))
EnvoiContribution.groupe_id == gid,
EnvoiContribution.debitee == True,
))
total_contributions = float(result.scalar())
# ── Encaisse ──
@ -453,12 +460,17 @@ async def etat_financier_temps_reel(
OperationBancaire.groupe_id == gid, OperationBancaire.type == "depot"))
total_depots = float(result.scalar())
result = await db.execute(
select(func.coalesce(func.sum(OperationBancaire.montant), 0)).where(
OperationBancaire.groupe_id == gid, OperationBancaire.type == "retrait"))
total_retraits = float(result.scalar())
result = await db.execute(
select(func.coalesce(func.sum(Depense.montant), 0)).where(
Depense.groupe_id == gid, Depense.paye == True, Depense.methode == "COLLECTE"))
depenses_cash = float(result.scalar())
encaisse = total_collectes + total_ventes - total_depots - depenses_cash
encaisse = total_collectes + total_ventes + total_retraits - total_depots - depenses_cash
# ── Solde banque ──
filtre_depuis = dernier_releve.date_operation if dernier_releve else None
@ -466,24 +478,40 @@ async def etat_financier_temps_reel(
OperationBancaire.groupe_id == gid, OperationBancaire.type == "depot")
q_retraits = select(func.coalesce(func.sum(OperationBancaire.montant), 0)).where(
OperationBancaire.groupe_id == gid, OperationBancaire.type == "retrait")
q_depenses_bancaires = select(func.coalesce(func.sum(Depense.montant), 0)).where(
Depense.groupe_id == gid,
Depense.paye == True,
Depense.methode.in_(["VIREMENT", "CHEQUE"]),
)
q_contributions = select(func.coalesce(func.sum(EnvoiContribution.montant), 0)).where(
EnvoiContribution.groupe_id == gid,
EnvoiContribution.debitee == True,
)
if filtre_depuis:
q_depots = q_depots.where(OperationBancaire.date_operation > filtre_depuis)
q_retraits = q_retraits.where(OperationBancaire.date_operation > filtre_depuis)
q_depenses_bancaires = q_depenses_bancaires.where(Depense.date_confirmation > filtre_depuis)
q_contributions = q_contributions.where(EnvoiContribution.date_envoi > filtre_depuis)
depots_depuis = float((await db.execute(q_depots)).scalar())
retraits_depuis = float((await db.execute(q_retraits)).scalar())
solde_banque = solde_releve + depots_depuis - retraits_depuis
depenses_bancaires_depuis = float((await db.execute(q_depenses_bancaires)).scalar())
contributions_depuis = float((await db.execute(q_contributions)).scalar())
solde_banque = solde_releve + depots_depuis - retraits_depuis - depenses_bancaires_depuis - contributions_depuis
# ── Réserves ──
reserve_prudente = 500.0
mois_depuis = 0
if groupe and groupe.cree_le:
from datetime import date as d
aujourdhui = d.today()
cree = groupe.cree_le.date() if hasattr(groupe.cree_le, 'date') else groupe.cree_le
mois_depuis = max(0, (aujourdhui.year - cree.year) * 12 + (aujourdhui.month - cree.month))
reserve_anniversaire = mois_depuis * 20.0
result = await db.execute(
select(Reserve).where(
Reserve.groupe_id == gid,
Reserve.active == True,
).order_by(Reserve.nom)
)
reserves_details = [
{"id": str(r.id), "nom": r.nom, "montant": float(r.montant), "note": r.note}
for r in result.scalars().all()
]
total_reserves = sum(r["montant"] for r in reserves_details)
disponible = solde_banque - total_engagements - reserve_prudente - reserve_anniversaire
disponible = solde_banque - total_engagements - total_reserves
# ── Dernières transactions ──
result = await db.execute(
@ -506,7 +534,7 @@ async def etat_financier_temps_reel(
"date_releve": str(dernier_releve.date_operation) if dernier_releve else None,
"solde_releve": round(solde_releve, 2),
"solde_banque": round(solde_banque, 2),
"encaisse": round(max(0, encaisse), 2),
"encaisse": round(encaisse, 2),
"disponible": round(disponible, 2),
"revenus": {
"collectes": round(total_collectes, 2),
@ -520,9 +548,8 @@ async def etat_financier_temps_reel(
"total": round(total_depenses_payees + total_contributions, 2),
},
"reserves": {
"prudente": reserve_prudente,
"anniversaire": round(reserve_anniversaire, 2),
"total": round(reserve_prudente + reserve_anniversaire, 2),
"details": reserves_details,
"total": round(total_reserves, 2),
},
"resultat_net": round(revenus - total_depenses_payees - total_contributions, 2),
"dernieres_collectes": dernieres_collectes,

View file

@ -53,6 +53,29 @@ class OperationBancaireOut(BaseModel):
model_config = {"from_attributes": True}
class ReserveCreate(BaseModel):
nom: str
montant: float
note: Optional[str] = None
class ReserveUpdate(BaseModel):
nom: Optional[str] = None
montant: Optional[float] = None
note: Optional[str] = None
class ReserveOut(BaseModel):
id: UUID
nom: str
montant: float
note: Optional[str] = None
active: bool = True
cree_le: datetime
model_config = {"from_attributes": True}
class SoldesOut(BaseModel):
encaisse: float # cash reçu par trésorier, pas encore déposé
compte_bancaire: float # dépôts - dépenses payées - contributions

View file

@ -21,7 +21,9 @@ class EnvoiContributionCreate(BaseModel):
destination: str # district, region, bsg, intergroupe
montant: float
methode: str # DU, VIREMENT, CHEQUE
resolution_id: UUID # résolution adoptée qui autorise l'envoi
resolution_id: Optional[UUID] = None
proposeur_id: UUID
secondeur_id: UUID
class EnvoiContributionOut(BaseModel):

View file

@ -1,6 +1,6 @@
"""Schémas Phase 8 — Votes (sondages) et parrainage (jumelages)."""
"""Schémas Phase 8 — Votes et sondages."""
from pydantic import BaseModel
from datetime import date, datetime
from datetime import datetime
from uuid import UUID
from typing import Optional
@ -52,27 +52,3 @@ class SondageUpdate(BaseModel):
class VoteCreate(BaseModel):
option_id: UUID
# ── G7 Jumelage parrain/filleul ──
class JumelageCreate(BaseModel):
parrain_id: UUID
filleul_id: UUID
date_jumelage: date
note: Optional[str] = None
class JumelageOut(BaseModel):
id: UUID
parrain_prenom: str
filleul_prenom: str
date_jumelage: date
statut: str
note: Optional[str] = None
cree_par_prenom: Optional[str] = None
model_config = {"from_attributes": True}
class JumelageUpdate(BaseModel):
statut: Optional[str] = None # actif, termine
note: Optional[str] = None

View file

@ -81,21 +81,6 @@ class PosteOut(BaseModel):
informations: Optional[str] = None
ordre_affichage: Optional[int] = None
actif: bool = True
taches: list[dict] = []
model_config = {"from_attributes": True}
class TacheCreate(BaseModel):
nom: str
informations: Optional[str] = None
class TacheOut(BaseModel):
id: UUID
nom: str
informations: Optional[str] = None
postes: list[str] = [] # noms des postes associés
model_config = {"from_attributes": True}
@ -117,6 +102,8 @@ class AffectationCreate(BaseModel):
membre_id: UUID
poste_id: UUID
date_debut: date
proposeur_id: UUID
secondeur_id: UUID
class TerminerMandatRequest(BaseModel):

View file

@ -37,13 +37,13 @@ from app.models.config_repartition import ConfigRepartition
from app.models.sondage import Sondage
from app.models.option_sondage import OptionSondage
from app.models.vote import Vote
from app.models.jumelage import Jumelage
from app.models.sync_log import SyncLog
from app.models.proposition import Proposition
from app.models.operation_bancaire import OperationBancaire
from app.models.evenement import Evenement
from app.models.candidature import Candidature
from app.models.tache import Tache, PosteTache
from app.models.poste_module import PosteModule
from app.models.reserve import Reserve
POSTES = [

122
docs/ANSIBLE.md Normal file
View file

@ -0,0 +1,122 @@
# Deploiement Ansible
Ce depot peut installer l'application complete sur une Debian vanille avec un compte `ansible` sudoer sans mot de passe.
Le playbook installe PostgreSQL, Node.js, Python, NGINX, le backend FastAPI, le frontend Vite compile, le service systemd, les sauvegardes quotidiennes et, optionnellement, le certificat Let's Encrypt.
## Prerequis cote poste de controle
```bash
python3 -m pip install --user ansible
ansible-galaxy collection install -r ansible/requirements.yml
node --version # Node.js 20 recommande pour construire le frontend PWA
npm --version
```
Le serveur cible doit etre joignable en SSH avec l'utilisateur `ansible`.
L'inventaire utilise explicitement cette cle locale:
```bash
~/.ssh/id_ed25519_ansible_chezlepro
```
Pour preparer l'acces SSH:
```bash
ssh-keygen -t ed25519 -f ~/.ssh/id_ed25519_ansible_chezlepro -C "ansible@chezlepro"
ssh-copy-id -i ~/.ssh/id_ed25519_ansible_chezlepro.pub ansible@ADRESSE_IP_VM
ssh -i ~/.ssh/id_ed25519_ansible_chezlepro ansible@ADRESSE_IP_VM
```
## Configuration
1. Modifier l'inventaire:
```bash
nano ansible/inventory/hosts.yml
```
Remplacer `ansible_host` par l'adresse de la machine Debian si elle change.
2. Creer le fichier de secrets:
```bash
cp ansible/group_vars/vault.yml.example ansible/group_vars/vault.yml
openssl rand -base64 48
nano ansible/group_vars/vault.yml
ansible-vault encrypt ansible/group_vars/vault.yml
```
Le fichier chiffre doit contenir au minimum:
- `groupe_meditation_db_password`
- `groupe_meditation_jwt_secret`
- `groupe_meditation_first_member`
3. Ajuster les variables non secretes au besoin:
```bash
nano ansible/group_vars/all.yml
```
Par defaut, le domaine est `groupe-meditation.87-16.org`.
## Installation
Depuis la racine du depot:
```bash
ansible-playbook ansible/site.yml --ask-vault-pass
```
Le playbook est concu pour etre relance. Il ne relance le seed que si la table `groupes` est absente ou vide.
Playbooks utiles:
```bash
ansible-playbook ansible/verify.yml --ask-vault-pass
ansible-playbook ansible/backup-now.yml --ask-vault-pass
```
## TLS
Laissez d'abord `groupe_meditation_enable_tls: false`, pointez le DNS vers la machine, puis activez:
```yaml
groupe_meditation_enable_tls: true
groupe_meditation_certbot_email: vous@example.com
```
Relancer ensuite:
```bash
ansible-playbook ansible/site.yml --ask-vault-pass
```
## Verification
```bash
curl -s http://groupe-meditation.87-16.org/api/health
ssh ansible@groupe-meditation.87-16.org 'sudo systemctl status groupe-meditation --no-pager'
ssh ansible@groupe-meditation.87-16.org 'sudo journalctl -u groupe-meditation -n 100 --no-pager'
```
## Ce que le playbook gere
- Paquets Debian requis.
- PostgreSQL local avec utilisateur et base applicatifs.
- `.env` backend avec permissions restrictives.
- Copie du code depuis ce depot vers `/opt/groupe-meditation`.
- Environnement virtuel Python et dependances backend.
- Installation npm et build frontend sur le poste de controle, puis publication de `dist/` sur la VM.
- Publication des fichiers statiques dans `/var/www/groupe-meditation`.
- Service systemd `groupe-meditation`.
- Site NGINX reverse proxy + SPA.
- Sauvegarde quotidienne dans `/var/backups/groupe-meditation`.
## Notes importantes
- Le playbook ne modifie pas le code applicatif.
- `node_modules/`, `dist/` et `ansible/group_vars/vault.yml` sont ignores par Git.
- La sauvegarde Ansible injecte `PGPASSWORD` dans le script cron root pour que le dump PostgreSQL fonctionne en mode non interactif.
- Le seed utilise le script existant `deploy/seed.py` et lui fournit les valeurs configurees dans Ansible.

426
docs/UTILISATEURS.md Normal file
View file

@ -0,0 +1,426 @@
# Guide utilisateur
Application du Groupe Méditation pour les membres, les serviteurs et l'exécutif du groupe.
Ce guide explique quoi faire dans l'application, sans détails techniques.
## 1. Accéder à l'application
### Se connecter
1. Ouvrir l'application dans le navigateur.
2. Choisir le groupe si plusieurs groupes sont affichés.
3. Entrer son courriel.
4. Entrer son PIN à 4 chiffres.
5. Cocher `Se souvenir de moi` seulement sur un appareil personnel.
6. Appuyer sur `Connexion`.
Si `Se souvenir de moi` n'est pas coché, la session reste limitée à l'onglet ou à la session du navigateur.
### Créer son compte
Un nouveau membre doit avoir un code d'invitation.
1. Sur l'écran de connexion, choisir `J'ai un code d'invitation`.
2. Entrer le code reçu.
3. Entrer les informations demandées.
4. Choisir un PIN à 4 chiffres.
5. Valider l'inscription.
Le code d'invitation ne peut être utilisé qu'une seule fois et expire automatiquement.
### Se déconnecter
Depuis l'accueil, appuyer sur `Déconnexion`.
## 2. Page d'accueil
L'accueil affiche les modules disponibles pour le membre connecté.
Les modules sont regroupés en trois sections :
- `Mes tâches` : saisies et actions liées au poste du membre.
- `Consultation` : modules accessibles à tous les membres.
- `Gestion` : modules réservés à l'exécutif ou aux postes autorisés.
Les accès dépendent des postes actifs du membre. Ils peuvent être ajustés dans `Gestion des postes`.
## 3. Modules de consultation
### Rapports PDF
Permet de consulter ou produire les rapports du groupe :
- finances;
- procès-verbaux;
- résolutions;
- rapports disponibles selon les données enregistrées.
### Événements
Affiche les événements du groupe et du district :
- anniversaires de sobriété;
- événements créés par l'exécutif;
- résolutions adoptées.
Les événements passés et futurs sont affichés. Les filtres peuvent être combinés : anniversaires, événements, résolutions.
Les membres de l'exécutif peuvent créer un événement.
### Littérature
Permet de consulter l'inventaire de littérature :
- titres disponibles;
- prix;
- quantités;
- alertes de stock bas.
### Décisions en cours
Affiche le suivi des décisions et propositions non terminées.
### Votes ouverts
Permet aux membres de voter dans les sondages ouverts.
### Mon profil
Permet de modifier :
- prénom;
- nom;
- téléphone;
- courriel;
- date d'abstinence;
- PIN.
Un courriel déjà utilisé dans le même groupe ne peut pas être repris.
### Propositions
Permet de :
- soumettre une proposition;
- consulter les propositions;
- seconder une proposition;
- suivre le statut des résolutions.
Une proposition suit normalement ce cycle :
1. proposée;
2. secondée;
3. votée;
4. adoptée ou rejetée.
Une proposition adoptée devient une résolution numérotée.
### Postes à pourvoir
Permet de consulter les postes vacants et de proposer sa candidature.
Si un membre est nominé à un poste, il peut accepter ou refuser la nomination.
### État financier
Affiche une vue rapide :
- solde bancaire;
- encaisse;
- montant disponible;
- résultat net;
- revenus;
- sorties;
- réserves;
- dernières collectes;
- dernières dépenses.
## 4. Modules de saisie accessibles à tous
### Collecte 7e Tradition
Permet de saisir une collecte.
Règles importantes :
- la collecte doit être associée à une date de réunion;
- il ne peut pas y avoir deux collectes pour la même date;
- un autre membre doit confirmer le montant;
- le trésorier doit accuser réception si la collecte n'est pas déjà reçue automatiquement.
Le module offre aussi une liste des collectes existantes.
### Dépense
Permet à un membre de soumettre une dépense.
La dépense n'est pas considérée comme payée tant que le trésorier ne l'a pas confirmée.
Le trésorier choisit la méthode :
- `COLLECTE` : payé depuis l'encaisse;
- `CHEQUE` ou `VIREMENT` : engagement à confirmer lorsque le débit apparaît;
- `DU` : montant dû au membre.
## 5. Trésorerie
Module réservé au trésorier ou aux postes autorisés.
### Soldes
Affiche :
- solde en banque;
- encaisse;
- montant disponible;
- réserves;
- engagements non débités;
- argent encore en circulation.
### Réceptions
Permet de traiter :
- collectes à recevoir;
- ventes de littérature remises au trésorier;
- contributions à confirmer comme débitées.
### Registre
Affiche les transactions financières.
On peut filtrer par :
- mois;
- année;
- type de transaction.
Types disponibles :
- collectes;
- ventes de littérature;
- dépenses;
- contributions;
- dépôts bancaires;
- retraits bancaires.
### Réserves
Permet de créer, modifier et supprimer les réserves.
Règle importante : le total des réserves ne peut jamais dépasser le solde en banque.
### Opérations bancaires
Permet d'enregistrer :
- un dépôt : transfert de l'encaisse vers la banque;
- un retrait : transfert de la banque vers l'encaisse;
- un relevé : solde réel du compte bancaire à une date donnée.
Le dépôt ne peut pas dépasser l'encaisse disponible.
## 6. Envoi contributions
Module réservé au trésorier.
Permet d'enregistrer une contribution vers :
- district;
- région;
- BSG;
- intergroupe.
Le module demande :
- destination;
- montant;
- méthode;
- proposeur;
- secondeur.
Lors de l'enregistrement, l'application crée une résolution de type `contribution`.
La contribution ne modifie pas les soldes immédiatement. Elle doit être confirmée comme débitée dans `Trésorerie` pour que le solde bancaire soit mis à jour.
## 7. Procès-verbal, présences et rapport RSG
### Rédiger PV
Permet de préparer un procès-verbal de réunion d'affaires.
Les propositions secondées et les décisions peuvent alimenter le contenu du PV.
### Présences
Permet de noter les membres présents à une réunion d'affaires.
### Rapport RSG
Permet au RSG ou au RSG substitut de saisir un rapport destiné au groupe.
## 8. Littérature et jetons
### Vente littérature
Permet d'enregistrer une vente.
Quand une vente est enregistrée :
- le stock est ajusté;
- le montant devient à remettre au trésorier;
- le trésorier confirme ensuite la réception dans `Trésorerie`.
### Ajuster inventaire
Permet au responsable de la littérature d'ajuster les quantités ou d'ajouter des éléments.
### Remise jeton
Permet d'enregistrer une remise de jeton.
### Commander jetons
Permet de préparer ou suivre les commandes de jetons.
## 9. Gestion des membres
Module réservé à l'exécutif ou aux postes autorisés.
Permet de :
- voir les membres actifs;
- consulter une fiche membre;
- générer un code d'invitation;
- copier ou partager le code;
- désactiver un membre;
- appliquer le droit à l'oubli.
### Désactiver un membre
Le membre ne peut plus se connecter, mais son historique demeure.
### Droit à l'oubli
Les données personnelles du membre sont anonymisées. Cette action est irréversible.
## 10. Gestion des mandats
Regroupe les actions liées aux mandats :
- affecter un poste;
- terminer un mandat;
- planifier les rotations.
### Affecter un poste
Une affectation demande :
- le membre;
- le poste;
- la date de début;
- un proposeur;
- un secondeur.
Une affectation crée automatiquement une résolution adoptée de type `nomination`.
### Terminer un mandat
Permet de fermer un mandat actif.
La fin d'un mandat n'est pas automatique. Elle doit être enregistrée manuellement.
## 11. Gestion des postes
Permet de :
- créer des postes;
- abolir des postes;
- gérer les candidatures;
- contrôler les accès aux modules selon les postes.
Les accès ne sont pas accordés directement aux personnes : ils passent par les postes.
## 12. Paramètres du groupe
Permet de modifier les informations générales :
- nom;
- district;
- région;
- adresse;
- jour de réunion;
- heure;
- format.
## 13. Sondages
### Lancer un sondage
Permet à l'exécutif de créer un sondage.
### Votes ouverts
Permet aux membres de voter.
## 14. Bonnes pratiques
- Utiliser son propre compte, jamais celui d'un autre membre.
- Ne pas partager son PIN.
- Se déconnecter sur un appareil partagé.
- Saisir les collectes et dépenses dès que possible.
- Confirmer les débits seulement lorsque l'argent est réellement sorti du compte.
- Enregistrer les dépôts quand l'encaisse est réellement déposée à la banque.
- Garder les réserves à jour.
- Utiliser les propositions et résolutions pour conserver une trace claire des décisions.
## 15. Problèmes fréquents
### Je ne peux pas me connecter
Vérifier :
- le bon groupe est sélectionné;
- le courriel est exact;
- le PIN contient 4 chiffres;
- le compte n'a pas été désactivé.
### Je ne vois pas un module
Les modules dépendent des postes et des accès configurés.
Demander à l'exécutif de vérifier :
- vos mandats actifs;
- les accès du poste dans `Gestion des postes`.
### Le dépôt est refusé
Le montant du dépôt dépasse probablement l'encaisse disponible.
### Une réserve est refusée
Le total des réserves dépasserait le solde en banque.
### Une contribution ou dépense ne change pas le solde
Elle doit être confirmée comme débitée dans `Trésorerie`.
### Une collecte ne peut pas être saisie
Causes possibles :
- date hors jour de réunion;
- collecte déjà enregistrée pour cette date;
- date future.
## 16. Règles importantes de l'application
- Les données sont séparées par groupe.
- Un membre se connecte avec son courriel et son PIN.
- Les actions financières sensibles sont réservées au trésorier ou à l'exécutif.
- Les résolutions adoptées sont numérotées automatiquement.
- Les mandats ne se terminent pas seuls.
- Les accès aux modules sont contrôlés par les postes.

6485
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -10,11 +10,8 @@ import Depense from './pages/Depense';
import Decisions from './pages/Decisions';
import Evenements from './pages/Evenements';
import Rapports from './pages/Rapports';
import InviterMembre from './pages/InviterMembre';
import GererMembres from './pages/GererMembres';
import AffecterPostes from './pages/AffecterPostes';
import TerminerMandat from './pages/TerminerMandat';
import PlanifierRotations from './pages/PlanifierRotations';
import GestionMandats from './pages/GestionMandats';
import Parametres from './pages/Parametres';
import Presences from './pages/Presences';
import RedigerPV from './pages/RedigerPV';
@ -28,7 +25,6 @@ import EnvoiContributions from './pages/EnvoiContributions';
import ConfigRepartition from './pages/ConfigRepartition';
import LancerSondage from './pages/LancerSondage';
import VotesOuverts from './pages/VotesOuverts';
import JumelerParrain from './pages/JumelerParrain';
import Propositions from './pages/Propositions';
import Tresorerie from './pages/Tresorerie';
import Impliquer from './pages/Impliquer';
@ -39,6 +35,8 @@ export default function App() {
const { token, page, loadAccueil, loadGroupes } = useStore();
useEffect(() => {
const handleUnauthorized = () => useStore.getState().logout();
window.addEventListener('gm:unauthorized', handleUnauthorized);
loadGroupes();
if (token) {
loadAccueil().then(() => {
@ -54,6 +52,7 @@ export default function App() {
useStore.setState({ page: 'login' });
});
}
return () => window.removeEventListener('gm:unauthorized', handleUnauthorized);
}, []);
// Pages protégées
@ -68,13 +67,10 @@ export default function App() {
case 'C1': return <Rapports />;
case 'C2': return <Evenements />;
case 'C5': return <Decisions />;
case 'G1': return <InviterMembre />;
case 'G2': return <GererMembres />;
case 'G3': return <AffecterPostes />;
case 'G4': return <TerminerMandat />;
case 'G5': return <PlanifierRotations />;
case 'G9': return <Parametres />;
case 'G10': return <GererPostes />;
case 'G11': return <GestionMandats />;
case 'G12': return <GererPostes />;
case 'M1': return <RedigerPV />;
case 'M2': return <Presences />;
case 'M3': return <RapportRSG />;
@ -87,7 +83,6 @@ export default function App() {
case 'G8': return <ConfigRepartition />;
case 'G6': return <LancerSondage />;
case 'C6': return <VotesOuverts />;
case 'G7': return <JumelerParrain />;
case 'C8': return <Propositions />;
case 'C9': return <Impliquer />;
case 'C10': return <EtatFinancier />;

View file

@ -39,7 +39,7 @@ function Section({ titre, modules }) {
<div className="grid gap-2">
{modules.map((m) => (
<Tuile key={m.code} module={m} onClick={() => {
const navigable = ['C7', 'S1', 'S1T', 'S2', 'S3', 'S4', 'S5', 'C1', 'C2', 'C4', 'C5', 'C6', 'C8', 'C9', 'C10', 'G1', 'G2', 'G3', 'G4', 'G5', 'G6', 'G7', 'G9', 'G10', 'M1', 'M2', 'M3', 'M4', 'M5', 'M6'];
const navigable = ['C7', 'S1', 'S1T', 'S2', 'S3', 'S4', 'S5', 'C1', 'C2', 'C4', 'C5', 'C6', 'C8', 'C9', 'C10', 'G2', 'G6', 'G9', 'G11', 'G12', 'M1', 'M2', 'M3', 'M4', 'M5', 'M6'];
if (m.code === 'C7') useStore.setState({ page: 'profil' });
else if (navigable.includes(m.code)) useStore.setState({ page: m.code });
else alert(`Module ${m.code} — ${m.nom}\n\n(Sera disponible dans les prochaines phases)`);
@ -75,11 +75,6 @@ export default function Accueil() {
))}
</div>
)}
{/* Lien aa87.org */}
<a href="https://aa87.org" target="_blank" rel="noopener noreferrer"
className="inline-block mt-3 bg-white/10 text-blue-100 text-xs px-3 py-2 rounded-lg active:bg-white/20">
📅 Événements du district →
</a>
</div>
{/* Modules */}

View file

@ -3,11 +3,17 @@ import { api } from '../stores/api';
import PageHeader from '../components/PageHeader';
import SuccessCheck from '../components/SuccessCheck';
export default function AffecterPostes() {
export default function AffecterPostes({ compact = false }) {
const [postes, setPostes] = useState([]);
const [membres, setMembres] = useState([]);
const [affectations, setAffectations] = useState([]);
const [form, setForm] = useState({ membre_id: '', poste_id: '', date_debut: new Date().toISOString().slice(0, 10) });
const [form, setForm] = useState({
membre_id: '',
poste_id: '',
date_debut: new Date().toISOString().slice(0, 10),
proposeur_id: '',
secondeur_id: '',
});
const [loading, setLoading] = useState(true);
const [success, setSuccess] = useState(false);
const [error, setError] = useState('');
@ -26,8 +32,12 @@ export default function AffecterPostes() {
}, []);
const handleSubmit = async () => {
if (!form.membre_id || !form.poste_id) {
setError('Sélectionnez un membre et un poste');
if (!form.membre_id || !form.poste_id || !form.proposeur_id || !form.secondeur_id) {
setError('Sélectionnez un membre, un poste, un proposeur et un secondeur');
return;
}
if (form.proposeur_id === form.secondeur_id) {
setError('Le proposeur et le secondeur doivent être différents');
return;
}
setError('');
@ -48,8 +58,8 @@ export default function AffecterPostes() {
const postesVacants = postes.filter((p) => !postesOccupes.has(p.nom));
return (
<div className="min-h-screen bg-gray-100">
<PageHeader titre="Affecter les postes" sousTitre="G3 — Assigner un serviteur" />
<div className={compact ? '' : 'min-h-screen bg-gray-100'}>
{!compact && <PageHeader titre="Affecter les postes" sousTitre="Assigner un serviteur" />}
<div className="p-4 space-y-4">
{/* Affectations actuelles */}
@ -75,7 +85,7 @@ export default function AffecterPostes() {
{postesVacants.length === 0 ? (
<div className="bg-yellow-50 rounded-xl p-3 border border-yellow-200">
<p className="text-sm text-yellow-700">Tous les postes sont pourvus. Terminez d'abord un mandat (G4) pour libérer un poste.</p>
<p className="text-sm text-yellow-700">Tous les postes sont pourvus. Terminez d'abord un mandat pour libérer un poste.</p>
</div>
) : (
<>
@ -98,6 +108,24 @@ export default function AffecterPostes() {
))}
</select>
</div>
<div>
<label className="text-xs text-gray-400 block mb-1">Proposeur</label>
<select value={form.proposeur_id} onChange={(e) => setForm({ ...form, proposeur_id: e.target.value })}
className="w-full p-3 bg-gray-50 rounded-xl text-lg border-0 focus:ring-2 focus:ring-aa-blue">
<option value="">Sélectionner un proposeur</option>
{membres.map((m) => <option key={m.id} value={m.id}>{m.prenom}</option>)}
</select>
</div>
<div>
<label className="text-xs text-gray-400 block mb-1">Secondeur</label>
<select value={form.secondeur_id} onChange={(e) => setForm({ ...form, secondeur_id: e.target.value })}
className="w-full p-3 bg-gray-50 rounded-xl text-lg border-0 focus:ring-2 focus:ring-aa-blue">
<option value="">Sélectionner un secondeur</option>
{membres.map((m) => <option key={m.id} value={m.id}>{m.prenom}</option>)}
</select>
</div>
</>
)}
@ -112,7 +140,7 @@ export default function AffecterPostes() {
{error && <p className="text-red-500 text-sm text-center bg-red-50 p-3 rounded-lg">{error}</p>}
<button onClick={handleSubmit}
disabled={!form.membre_id || !form.poste_id}
disabled={!form.membre_id || !form.poste_id || !form.proposeur_id || !form.secondeur_id}
className="w-full p-5 bg-purple-600 text-white rounded-2xl text-xl font-bold disabled:opacity-40 active:scale-[0.97] transition shadow-lg">
🎯 Affecter le poste
</button>

View file

@ -16,6 +16,8 @@ export default function Collecte() {
const [detenteurId, setDetenteurId] = useState('');
const [membres, setMembres] = useState([]);
const [collectesMois, setCollectesMois] = useState([]);
const [collectesToutes, setCollectesToutes] = useState([]);
const [collecteSelectionneeId, setCollecteSelectionneeId] = useState('');
const [tab, setTab] = useState('mois');
const [loading, setLoading] = useState(false);
const [msg, setMsg] = useState('');
@ -27,13 +29,16 @@ export default function Collecte() {
const charger = async () => {
try {
const [mbrs, cols] = await Promise.all([
const [mbrs, cols, toutes] = await Promise.all([
api.get('/membres/actifs'),
api.get(`/collectes/?mois=${mois}&annee=${annee}&limite=50`),
api.get('/collectes/?limite=500'),
]);
setMembres(mbrs);
setCollectesMois(cols);
setCollectesToutes(toutes);
if (membre && !detenteurId) setDetenteurId(membre.id);
if (!collecteSelectionneeId && toutes.length > 0) setCollecteSelectionneeId(toutes[0].id);
} catch (e) { setError(e.message); }
};
@ -70,30 +75,42 @@ export default function Collecte() {
};
const totalMois = collectesMois.reduce((sum, c) => sum + parseFloat(c.montant), 0);
const collecteSelectionnee = collectesToutes.find((c) => c.id === collecteSelectionneeId);
const MOIS_NOMS = ['', 'janvier', 'février', 'mars', 'avril', 'mai', 'juin',
'juillet', 'août', 'septembre', 'octobre', 'novembre', 'décembre'];
const dateLocale = (d = new Date()) => {
const an = d.getFullYear();
const mo = String(d.getMonth() + 1).padStart(2, '0');
const jo = String(d.getDate()).padStart(2, '0');
return `${an}-${mo}-${jo}`;
};
const labelDate = (d, avecAnnee = false) => {
const dt = new Date(d + 'T12:00:00');
return dt.toLocaleDateString('fr-CA', {
weekday: 'long',
day: 'numeric',
month: 'long',
...(avecAnnee ? { year: 'numeric' } : {}),
});
};
// Calculer les dernières dates de réunion pour le sélecteur
const joursSemaine = { 'lundi': 1, 'mardi': 2, 'mercredi': 3, 'jeudi': 4, 'vendredi': 5, 'samedi': 6, 'dimanche': 0 };
const jourReunion = groupe?.jour_reunion ? joursSemaine[groupe.jour_reunion.toLowerCase()] : null;
const getDatesReunion = () => {
if (jourReunion === null) return [];
const datesAvecCollecte = new Set(collectesToutes.map((c) => c.date_collecte));
const dates = [];
const d = new Date();
for (let i = 0; i < 12; i++) {
for (let i = 0; i < 180; i++) {
const test = new Date(d);
test.setDate(d.getDate() - i);
const valeur = dateLocale(test);
if (test.getDay() === jourReunion) {
dates.push(test.toISOString().slice(0, 10));
}
}
// Aussi vérifier les 30 jours suivants dans le passé
for (let i = 12; i < 60; i++) {
const test = new Date(d);
test.setDate(d.getDate() - i);
if (test.getDay() === jourReunion) {
dates.push(test.toISOString().slice(0, 10));
if (!datesAvecCollecte.has(valeur)) dates.push(valeur);
}
}
return dates;
@ -101,6 +118,12 @@ export default function Collecte() {
const datesReunion = getDatesReunion();
useEffect(() => {
if (datesReunion.length > 0 && !datesReunion.includes(dateCollecte)) {
setDateCollecte(datesReunion[0]);
}
}, [collectesToutes, groupe?.jour_reunion]);
return (
<div className="min-h-screen bg-gray-100">
<PageHeader titre="Collecte 7e Tradition" sousTitre="S1 — Saisir et confirmer" />
@ -134,6 +157,42 @@ export default function Collecte() {
</div>
</div>
{collectesToutes.length > 0 && (
<div className="bg-white rounded-2xl p-4 shadow-sm space-y-3">
<div>
<label className="text-xs text-gray-400 block mb-1">Toutes les collectes</label>
<select value={collecteSelectionneeId} onChange={(e) => setCollecteSelectionneeId(e.target.value)}
className="w-full p-3 bg-gray-50 rounded-xl text-sm border-0 focus:ring-2 focus:ring-aa-blue">
{collectesToutes.map((c) => (
<option key={c.id} value={c.id}>
{c.date_collecte} · {parseFloat(c.montant).toFixed(2)} $ · {STATUT[c.statut]?.label || c.statut}
</option>
))}
</select>
</div>
{collecteSelectionnee && (
<div className="rounded-xl bg-gray-50 p-3 text-sm">
<div className="flex items-center justify-between gap-2">
<p className="font-bold text-gray-800">{labelDate(collecteSelectionnee.date_collecte, true)}</p>
<span className={`text-xs px-2 py-1 rounded-full font-medium ${STATUT[collecteSelectionnee.statut]?.cls || STATUT.en_attente.cls}`}>
{STATUT[collecteSelectionnee.statut]?.label || collecteSelectionnee.statut}
</span>
</div>
<p className="mt-1 text-lg font-bold text-green-700">{parseFloat(collecteSelectionnee.montant).toFixed(2)} $</p>
<p className="text-xs text-gray-500">
Saisi par {collecteSelectionnee.saisi_par_prenom || '—'} · Chez {collecteSelectionnee.detenteur_prenom || '—'}
</p>
{collecteSelectionnee.confirme_par_prenom && (
<p className="text-xs text-blue-500">Confirmé par {collecteSelectionnee.confirme_par_prenom}</p>
)}
{collecteSelectionnee.recu_par_prenom && (
<p className="text-xs text-green-500">Reçu par {collecteSelectionnee.recu_par_prenom}</p>
)}
</div>
)}
</div>
)}
{collectesMois.length === 0 ? (
<div className="text-center py-12">
<p className="text-4xl mb-2">💰</p>
@ -181,18 +240,23 @@ export default function Collecte() {
<select value={dateCollecte} onChange={(e) => setDateCollecte(e.target.value)}
className="w-full p-3 bg-gray-50 rounded-xl text-lg border-0 focus:ring-2 focus:ring-aa-blue">
{datesReunion.map((d) => {
const dt = new Date(d + 'T12:00:00');
const label = dt.toLocaleDateString('fr-CA', { weekday: 'long', day: 'numeric', month: 'long' });
const estAujourdhui = d === new Date().toISOString().slice(0, 10);
const label = labelDate(d);
const estAujourdhui = d === dateLocale();
return <option key={d} value={d}>{estAujourdhui ? '● Aujourd\'hui' : label}</option>;
})}
</select>
) : (
) : jourReunion === null ? (
<input type="date" value={dateCollecte} onChange={(e) => setDateCollecte(e.target.value)}
max={new Date().toISOString().slice(0, 10)}
max={dateLocale()}
className="w-full p-3 bg-gray-50 rounded-xl text-lg border-0 focus:ring-2 focus:ring-aa-blue" />
) : (
<div className="rounded-xl bg-gray-50 p-3 text-sm text-gray-500">
Aucun {groupe?.jour_reunion || 'jour de réunion'} sans collecte enregistrée dans les derniers mois.
</div>
)}
<p className="text-xs text-gray-400 mt-1">Saisie rétroactive permise aux dates de réunion</p>
<p className="text-xs text-gray-400 mt-1">
Seuls les {groupe?.jour_reunion || 'jours de réunion'} sans collecte enregistrée sont proposés.
</p>
</div>
<div className="bg-white rounded-2xl p-4 shadow-sm">
@ -217,7 +281,7 @@ export default function Collecte() {
</select>
</div>
<button onClick={handleSubmit} disabled={loading || !montant || !detenteurId}
<button onClick={handleSubmit} disabled={loading || !montant || !detenteurId || (datesReunion.length === 0 && jourReunion !== null)}
className="w-full p-5 bg-green-600 text-white rounded-2xl text-xl font-bold disabled:opacity-40 active:scale-[0.97] transition shadow-lg">
{loading ? '...' : '💰 Enregistrer la collecte'}
</button>

View file

@ -65,7 +65,7 @@ export default function Depense() {
formData.append('fichier', fichier);
await fetch(`/api/depenses/${result.id}/preuve`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${localStorage.getItem('gm_token')}` },
headers: { 'Authorization': `Bearer ${api.getToken()}` },
body: formData,
});
}

View file

@ -17,7 +17,7 @@ const DESTINATIONS = [
export default function EnvoiContributions() {
const [soldes, setSoldes] = useState(null);
const [resolutions, setResolutions] = useState([]);
const [membres, setMembres] = useState([]);
const [envois, setEnvois] = useState([]);
const [success, setSuccess] = useState(false);
const [error, setError] = useState('');
@ -28,17 +28,18 @@ export default function EnvoiContributions() {
destination: 'district',
montant: '',
methode: '',
resolution_id: '',
proposeur_id: '',
secondeur_id: '',
});
useEffect(() => {
Promise.all([
api.get('/collectes/soldes'),
api.get('/propositions/resolutions?type_proposition=contribution'),
api.get('/membres/actifs'),
api.get('/contributions/?limite=10'),
]).then(([s, r, e]) => {
]).then(([s, m, e]) => {
setSoldes(s);
setResolutions(r);
setMembres(m);
setEnvois(e);
if (s && s.disponible > 0) {
setForm((f) => ({ ...f, montant: s.disponible.toFixed(2) }));
@ -51,10 +52,8 @@ export default function EnvoiContributions() {
const handleSubmit = async () => {
if (!form.montant || parseFloat(form.montant) <= 0) { setError('Montant invalide'); return; }
if (!form.methode) { setError('Sélectionnez une méthode'); return; }
if (!form.resolution_id) {
setError('Une résolution adoptée est requise. Créez d\'abord une proposition de type « contribution », faites-la seconder et adopter.');
return;
}
if (!form.proposeur_id || !form.secondeur_id) { setError('Sélectionnez un proposeur et un secondeur'); return; }
if (form.proposeur_id === form.secondeur_id) { setError('Le proposeur et le secondeur doivent être différents'); return; }
setLoading(true); setError('');
try {
await api.post('/contributions/', {
@ -62,17 +61,18 @@ export default function EnvoiContributions() {
destination: form.destination,
montant: parseFloat(form.montant),
methode: form.methode,
resolution_id: form.resolution_id,
proposeur_id: form.proposeur_id,
secondeur_id: form.secondeur_id,
});
setSuccess(true);
} catch (e) { setError(e.message); setLoading(false); }
};
if (success) return <SuccessCheck message="Contribution enregistrée ✓" />;
if (success) return <SuccessCheck message="Contribution engagée ✓" />;
return (
<div className="min-h-screen bg-gray-100">
<PageHeader titre="Envoi contributions" sousTitre="M4 — Requiert une résolution" />
<PageHeader titre="Envoi contributions" sousTitre="M4 — Crée la résolution" />
<div className="p-4 space-y-4">
{/* Somme disponible */}
@ -93,29 +93,30 @@ export default function EnvoiContributions() {
</div>
)}
{/* Résolution obligatoire */}
<div className="bg-white rounded-2xl p-4 shadow-sm">
<label className="text-xs text-gray-400 block mb-2">Résolution autorisant l'envoi *</label>
{resolutions.length === 0 ? (
<div className="bg-yellow-50 rounded-xl p-3 border border-yellow-200">
<p className="text-sm text-yellow-700">
Aucune résolution de type « contribution » disponible.
</p>
<p className="text-xs text-yellow-600 mt-1">
Créez une proposition de type « contribution » dans la section Propositions, faites-la seconder puis adopter lors d'une assemblée d'affaires.
</p>
</div>
) : (
<select value={form.resolution_id} onChange={(e) => update('resolution_id', e.target.value)}
<div className="bg-blue-50 rounded-2xl p-4 border border-blue-200">
<p className="text-sm text-blue-700 font-medium">Une résolution de contribution sera créée automatiquement.</p>
<p className="text-xs text-blue-600 mt-1">
L'envoi sera ensuite visible dans Trésorerie, où le débit devra être confirmé pour mettre les soldes à jour.
</p>
</div>
<div className="bg-white rounded-2xl p-4 shadow-sm space-y-3">
<div>
<label className="text-xs text-gray-400 block mb-1">Proposé par</label>
<select value={form.proposeur_id} onChange={(e) => update('proposeur_id', e.target.value)}
className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue">
<option value="">— Sélectionner une résolution —</option>
{resolutions.map((r) => (
<option key={r.id} value={r.id}>
{r.numero_resolution} — {r.sujet}
</option>
))}
<option value="">— Sélectionner —</option>
{membres.map((m) => <option key={m.id} value={m.id}>{m.prenom}</option>)}
</select>
)}
</div>
<div>
<label className="text-xs text-gray-400 block mb-1">Secondé par</label>
<select value={form.secondeur_id} onChange={(e) => update('secondeur_id', e.target.value)}
className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue">
<option value="">— Sélectionner —</option>
{membres.map((m) => <option key={m.id} value={m.id}>{m.prenom}</option>)}
</select>
</div>
</div>
<div className="bg-white rounded-2xl p-4 shadow-sm">
@ -161,9 +162,9 @@ export default function EnvoiContributions() {
{error && <p className="text-red-500 text-sm text-center bg-red-50 p-3 rounded-lg">{error}</p>}
<button onClick={handleSubmit}
disabled={loading || !form.montant || !form.methode || !form.resolution_id}
disabled={loading || !form.montant || !form.methode || !form.proposeur_id || !form.secondeur_id}
className="w-full p-5 bg-blue-600 text-white rounded-2xl text-xl font-bold disabled:opacity-40 active:scale-[0.97] transition shadow-lg">
{loading ? '...' : '📮 Enregistrer l\'envoi'}
{loading ? '...' : '📮 Créer l\'envoi'}
</button>
{/* Historique */}
@ -174,7 +175,9 @@ export default function EnvoiContributions() {
<div key={e.id} className="bg-white rounded-xl p-3 shadow-sm flex items-center gap-3">
<div className="flex-1 min-w-0">
<p className="font-bold text-sm">{e.destination_label}</p>
<p className="text-xs text-gray-400">{e.date_envoi} · {e.methode}</p>
<p className="text-xs text-gray-400">
{e.date_envoi} · {e.methode} · {e.debitee ? 'Débité' : 'À débiter'}
</p>
</div>
<p className="font-bold text-sm">{e.montant.toFixed(2)} $</p>
</div>

View file

@ -42,7 +42,7 @@ export default function EtatFinancier() {
<Ligne label="Ventes de littérature" montant={data.revenus.ventes_litterature} />
<div className="border-t border-green-200 mt-2 pt-2 flex justify-between">
<span className="font-bold text-green-800 text-sm">Total revenus</span>
<span className="font-bold text-green-800 text-sm">{data.revenus.total.toFixed(2)} $</span>
<span className="font-bold text-green-800 text-sm">{formatMontant(data.revenus?.total)} $</span>
</div>
</div>
@ -54,36 +54,41 @@ export default function EtatFinancier() {
<Ligne label="Contributions envoyées" montant={data.sorties.contributions} />
<div className="border-t border-red-200 mt-2 pt-2 flex justify-between">
<span className="font-bold text-red-800 text-sm">Total sorties</span>
<span className="font-bold text-red-800 text-sm">{data.sorties.total.toFixed(2)} $</span>
<span className="font-bold text-red-800 text-sm">{formatMontant(data.sorties?.total)} $</span>
</div>
</div>
{/* Réserves */}
<div className="bg-amber-50 rounded-2xl p-4 border border-amber-200">
<h2 className="text-xs font-bold text-amber-700 uppercase tracking-wider mb-2">Réserves</h2>
<Ligne label="Réserve prudente" montant={data.reserves.prudente} />
<Ligne label="Réserve anniversaire (+20$/mois)" montant={data.reserves.anniversaire} />
{(data.reserves?.details || []).length > 0 ? (
data.reserves.details.map((reserve) => (
<Ligne key={reserve.id} label={reserve.nom} montant={reserve.montant} />
))
) : (
<p className="text-sm text-amber-700">Aucune réserve enregistrée.</p>
)}
<div className="border-t border-amber-200 mt-2 pt-2 flex justify-between">
<span className="font-bold text-amber-800 text-sm">Total réserves</span>
<span className="font-bold text-amber-800 text-sm">{data.reserves.total.toFixed(2)} $</span>
<span className="font-bold text-amber-800 text-sm">{formatMontant(data.reserves?.total)} $</span>
</div>
</div>
{/* Dernières collectes */}
{data.dernieres_collectes.length > 0 && (
{(data.dernieres_collectes || []).length > 0 && (
<div className="bg-white rounded-2xl p-4 shadow-sm">
<h2 className="text-xs font-bold text-gray-400 uppercase tracking-wider mb-2">Dernières collectes</h2>
{data.dernieres_collectes.map((c, i) => (
<div key={i} className="flex justify-between py-1.5 border-b border-gray-50 last:border-0">
<span className="text-sm text-gray-600">{c.date}</span>
<span className="text-sm font-bold text-green-600">+{c.montant.toFixed(2)} $</span>
<span className="text-sm font-bold text-green-600">+{formatMontant(c.montant)} $</span>
</div>
))}
</div>
)}
{/* Dernières dépenses */}
{data.dernieres_depenses.length > 0 && (
{(data.dernieres_depenses || []).length > 0 && (
<div className="bg-white rounded-2xl p-4 shadow-sm">
<h2 className="text-xs font-bold text-gray-400 uppercase tracking-wider mb-2">Dernières dépenses</h2>
{data.dernieres_depenses.map((d, i) => (
@ -92,7 +97,7 @@ export default function EtatFinancier() {
<p className="text-sm text-gray-600 truncate">{d.quoi}</p>
<p className="text-xs text-gray-400">{d.date} · {d.methode || '—'} · {d.paye ? '✓' : '⏳'}</p>
</div>
<span className="text-sm font-bold text-red-600 whitespace-nowrap ml-2">-{d.montant.toFixed(2)} $</span>
<span className="text-sm font-bold text-red-600 whitespace-nowrap ml-2">-{formatMontant(d.montant)} $</span>
</div>
))}
</div>
@ -113,7 +118,7 @@ function Bloc({ label, montant, couleur }) {
return (
<div className={`text-center p-3 rounded-xl ${cls[couleur] || cls.blue}`}>
<p className="text-xs opacity-70">{label}</p>
<p className="text-xl font-bold">{montant.toFixed(2)} $</p>
<p className="text-xl font-bold">{formatMontant(montant)} $</p>
</div>
);
}
@ -122,7 +127,12 @@ function Ligne({ label, montant }) {
return (
<div className="flex justify-between py-1">
<span className="text-sm text-gray-600">{label}</span>
<span className="text-sm font-medium">{montant.toFixed(2)} $</span>
<span className="text-sm font-medium">{formatMontant(montant)} $</span>
</div>
);
}
function formatMontant(montant) {
const valeur = Number(montant);
return Number.isFinite(valeur) ? valeur.toFixed(2) : '0.00';
}

View file

@ -12,7 +12,7 @@ const TYPE_STYLE = {
export default function Evenements() {
const { postes } = useStore();
const [evenements, setEvenements] = useState([]);
const [filtre, setFiltre] = useState('tous');
const [filtres, setFiltres] = useState(['anniversaire', 'adhoc', 'resolution']);
const [loading, setLoading] = useState(true);
const [showForm, setShowForm] = useState(false);
const [error, setError] = useState('');
@ -30,7 +30,7 @@ export default function Evenements() {
const charger = async () => {
try {
const data = await api.get('/evenements/?jours=90');
const data = await api.get('/evenements/?jours=365&jours_avant=365');
setEvenements(data);
} catch (e) { setError(e.message); }
setLoading(false);
@ -52,11 +52,17 @@ export default function Evenements() {
} catch (e) { setError(e.message); }
};
const filtres = evenements.filter((e) => filtre === 'tous' || e.type === filtre);
const toggleFiltre = (type) => {
setFiltres((actifs) => (
actifs.includes(type) ? actifs.filter((t) => t !== type) : [...actifs, type]
));
};
const evenementsFiltres = evenements.filter((e) => filtres.includes(e.type));
// Grouper par date
const parDate = {};
filtres.forEach((e) => {
evenementsFiltres.forEach((e) => {
if (!parDate[e.date]) parDate[e.date] = [];
parDate[e.date].push(e);
});
@ -65,7 +71,7 @@ export default function Evenements() {
return (
<div className="min-h-screen bg-gray-100">
<PageHeader titre="Événements à venir" sousTitre="Anniversaires et événements spéciaux" />
<PageHeader titre="Événements" sousTitre="Anniversaires, événements et résolutions" />
<div className="p-4 space-y-4">
{msg && <div className="bg-green-50 text-green-700 p-3 rounded-xl text-center text-sm font-medium">{msg}</div>}
@ -73,10 +79,10 @@ export default function Evenements() {
{/* Filtres */}
<div className="flex gap-2 overflow-x-auto">
{[['tous', 'Tous'], ['anniversaire', '🎂 Anniv.'], ['adhoc', '⭐ Événements'], ['resolution', '📜 Résol.']].map(([k, label]) => (
<button key={k} onClick={() => setFiltre(k)}
{[['anniversaire', '🎂 Anniv.'], ['adhoc', '⭐ Événements'], ['resolution', '📜 Résol.']].map(([k, label]) => (
<button key={k} onClick={() => toggleFiltre(k)}
className={`px-3 py-1.5 rounded-full text-xs font-bold whitespace-nowrap transition ${
filtre === k ? 'bg-aa-blue text-white' : 'bg-white text-gray-500'
filtres.includes(k) ? 'bg-aa-blue text-white' : 'bg-white text-gray-500'
}`}>{label}</button>
))}
{estExecutif && (
@ -108,10 +114,10 @@ export default function Evenements() {
)}
{/* Liste groupée par date */}
{filtres.length === 0 ? (
{evenementsFiltres.length === 0 ? (
<div className="text-center py-12">
<p className="text-4xl mb-2">📅</p>
<p className="text-gray-500">Aucun événement à venir</p>
<p className="text-gray-500">Aucun événement pour ces filtres</p>
</div>
) : (
Object.entries(parDate).map(([dateStr, evs]) => {

View file

@ -8,6 +8,10 @@ export default function GererMembres() {
const [detail, setDetail] = useState(null);
const [confirmAction, setConfirmAction] = useState(null);
const [msg, setMsg] = useState('');
const [code, setCode] = useState(null);
const [expiration, setExpiration] = useState(null);
const [inviteLoading, setInviteLoading] = useState(false);
const [copied, setCopied] = useState(false);
const charger = async () => {
setLoading(true);
@ -20,6 +24,28 @@ export default function GererMembres() {
useEffect(() => { charger(); }, []);
const genererInvitation = async () => {
setInviteLoading(true);
try {
const data = await api.post('/invitations/');
setCode(data.code);
setExpiration(new Date(data.expire_le).toLocaleString('fr-CA'));
} catch (e) { alert(e.message); }
setInviteLoading(false);
};
const copierInvitation = () => {
navigator.clipboard.writeText(code).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
};
const partagerSMS = () => {
const texte = `Rejoins le Groupe Méditation sur l'app. Ton code d'invitation : ${code}`;
window.open(`sms:?body=${encodeURIComponent(texte)}`);
};
const desactiver = async (id) => {
try {
const res = await api.patch(`/membres/${id}/desactiver`);
@ -122,9 +148,40 @@ export default function GererMembres() {
return (
<div className="min-h-screen bg-gray-100">
<PageHeader titre="Gérer les membres" sousTitre="G2 — Fiche complète, désactiver" />
<div className="p-4 space-y-2">
<PageHeader titre="Gestion des membres" sousTitre="Membres, invitations, désactivation" />
<div className="p-4 space-y-4">
{msg && <div className="bg-green-50 text-green-700 p-3 rounded-xl text-center text-sm font-medium mb-2">{msg}</div>}
<div className="bg-white rounded-2xl p-4 shadow-sm space-y-3">
<div className="flex items-center justify-between gap-3">
<div>
<h2 className="font-bold text-gray-800">Inviter un membre</h2>
<p className="text-xs text-gray-400">Code à usage unique, expiration automatique.</p>
</div>
<button onClick={genererInvitation} disabled={inviteLoading}
className="px-4 py-2 bg-green-600 text-white rounded-xl text-sm font-bold disabled:opacity-40">
{inviteLoading ? '...' : 'Générer'}
</button>
</div>
{code && (
<div className="bg-green-50 rounded-xl p-3 border border-green-100">
<p className="text-xs text-green-700">Code d'invitation</p>
<p className="text-2xl font-mono font-bold tracking-widest text-green-800">{code}</p>
<p className="text-xs text-green-600 mt-1">Expire le {expiration}</p>
<div className="flex gap-2 mt-3">
<button onClick={copierInvitation}
className="flex-1 py-2 bg-white text-green-700 rounded-xl text-sm font-bold border border-green-200">
{copied ? 'Copié' : 'Copier'}
</button>
<button onClick={partagerSMS}
className="flex-1 py-2 bg-green-600 text-white rounded-xl text-sm font-bold">
SMS
</button>
</div>
</div>
)}
</div>
{loading ? (
<p className="text-center text-gray-400 py-8">Chargement...</p>
) : (

View file

@ -8,40 +8,39 @@ const CATEGORIES = [
{ value: 'physique', label: 'Physique' },
];
const MODULES = [
['S1T', 'Trésorerie'], ['M4', 'Envoi contributions'],
['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'],
['G2', 'Gestion membres'], ['G6', 'Sondages'], ['G9', 'Paramètres'],
['G11', 'Gestion mandats'], ['G12', 'Gestion postes'],
];
export default function GererPostes() {
const [postes, setPostes] = useState([]);
const [taches, setTaches] = useState([]);
const [modulesPostes, setModulesPostes] = useState({});
const [candidatures, setCandidatures] = useState([]);
const [tab, setTab] = useState('postes');
const [showForm, setShowForm] = useState(false);
const [showTacheForm, setShowTacheForm] = useState(false);
const [msg, setMsg] = useState('');
const [error, setError] = useState('');
// Form poste
const [nom, setNom] = useState('');
const [categorie, setCategorie] = useState('service');
const [description, setDescription] = useState('');
const [prerequis, setPrerequis] = useState('');
const [informations, setInformations] = useState('');
// Form tache
const [tacheNom, setTacheNom] = useState('');
const [tacheInfo, setTacheInfo] = useState('');
// Affectation tache
const [affectPosteId, setAffectPosteId] = useState('');
const [affectTacheId, setAffectTacheId] = useState('');
const charger = async () => {
try {
const [p, t, c] = await Promise.all([
const [p, mp, c] = await Promise.all([
api.get('/postes/?inclure_abolis=true'),
api.get('/postes/taches'),
api.get('/postes/modules'),
api.get('/postes/candidatures'),
]);
setPostes(p);
setTaches(t);
setModulesPostes(mp);
setCandidatures(c);
} catch (e) { setError(e.message); }
};
@ -84,49 +83,24 @@ export default function GererPostes() {
};
const supprimerPermanent = async (id, nomPoste) => {
if (!confirm(`Supprimer DÉFINITIVEMENT le poste «${nomPoste}» ?\nCette action est irréversible.`)) return;
if (!confirm(`Supprimer définitivement le poste «${nomPoste}» ?`)) return;
try {
await api.request('DELETE', `/postes/${id}/permanent`);
flash('Poste supprimé définitivement');
flash('Poste supprimé');
await charger();
} catch (e) { setError(e.message); }
};
const creerTache = async () => {
if (!tacheNom.trim()) { setError('Nom requis'); return; }
const toggleModule = async (posteId, code) => {
const actuel = new Set(modulesPostes[posteId] || []);
if (actuel.has(code)) actuel.delete(code);
else actuel.add(code);
const codes = [...actuel];
setModulesPostes({ ...modulesPostes, [posteId]: codes });
try {
await api.post('/postes/taches', { nom: tacheNom.trim(), informations: tacheInfo.trim() || null });
setTacheNom(''); setTacheInfo(''); setShowTacheForm(false);
flash('Tâche créée ✓');
await charger();
} catch (e) { setError(e.message); }
};
const supprimerTache = async (id, nomT) => {
if (!confirm(`Supprimer la tâche «${nomT}» ?`)) return;
try {
await api.request('DELETE', `/postes/taches/${id}`);
flash('Tâche supprimée');
await charger();
} catch (e) { setError(e.message); }
};
const affecterTache = async () => {
if (!affectPosteId || !affectTacheId) { setError('Sélectionnez un poste et une tâche'); return; }
try {
await api.post(`/postes/${affectPosteId}/taches/${affectTacheId}`);
setAffectTacheId('');
flash('Tâche affectée ✓');
await charger();
} catch (e) { setError(e.message); }
};
const desaffecterTache = async (posteId, tacheId) => {
try {
await api.request('DELETE', `/postes/${posteId}/taches/${tacheId}`);
flash('Tâche retirée');
await charger();
} catch (e) { setError(e.message); }
await api.request('PUT', `/postes/${posteId}/modules`, codes);
flash('Accès mis à jour ✓');
} catch (e) { setError(e.message); await charger(); }
};
const traiterCandidature = async (id, statut) => {
@ -137,12 +111,11 @@ export default function GererPostes() {
} catch (e) { setError(e.message); }
};
const postesActifs = postes.filter((p) => p.actif);
const CAT_ICON = { executif: '🔵', service: '🟣', physique: '🟢' };
return (
<div className="min-h-screen bg-gray-100">
<PageHeader titre="Gestion des postes" sousTitre="Postes, tâches, candidatures" />
<PageHeader titre="Gestion des postes" sousTitre="Postes, candidatures, accès aux modules" />
<div className="p-4 space-y-4">
{msg && <div className="bg-green-50 text-green-700 p-3 rounded-xl text-center text-sm font-medium">{msg}</div>}
@ -150,9 +123,9 @@ export default function GererPostes() {
<div className="flex gap-1 overflow-x-auto">
{[
['postes', `📋 Postes (${postes.length})`],
['taches', `🔧 Tâches (${taches.length})`],
['candidatures', `🙋 Candid. (${candidatures.length})`],
['postes', `Postes (${postes.length})`],
['acces', 'Accès'],
['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 ${
@ -161,34 +134,30 @@ export default function GererPostes() {
))}
</div>
{/* ── Postes ── */}
{tab === 'postes' && (
<div className="space-y-3">
<button onClick={() => setShowForm(!showForm)}
className="w-full py-3 bg-green-50 text-green-700 rounded-xl font-bold border border-green-200 active:scale-[0.97]">
className="w-full py-3 bg-green-50 text-green-700 rounded-xl font-bold border border-green-200">
+ Créer un poste
</button>
{showForm && (
<div className="bg-white rounded-2xl p-4 shadow-sm space-y-3">
<input type="text" value={nom} onChange={(e) => setNom(e.target.value)}
placeholder="Nom du poste" className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue" autoFocus />
<input value={nom} onChange={(e) => setNom(e.target.value)} placeholder="Nom du poste"
className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue" />
<select value={categorie} onChange={(e) => setCategorie(e.target.value)}
className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue">
{CATEGORIES.map((c) => <option key={c.value} value={c.value}>{c.label}</option>)}
</select>
<input type="text" value={description} onChange={(e) => setDescription(e.target.value)}
placeholder="Description" className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue" />
<input type="text" value={prerequis} onChange={(e) => setPrerequis(e.target.value)}
placeholder="Prérequis" className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue" />
<textarea value={informations} onChange={(e) => setInformations(e.target.value)}
placeholder="Informations complémentaires" rows={2}
className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue resize-none" />
<input value={description} onChange={(e) => setDescription(e.target.value)} placeholder="Description"
className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue" />
<input value={prerequis} onChange={(e) => setPrerequis(e.target.value)} placeholder="Prérequis"
className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue" />
<textarea value={informations} onChange={(e) => setInformations(e.target.value)} placeholder="Informations"
rows={2} className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue resize-none" />
<button onClick={creerPoste} disabled={!nom.trim()}
className="w-full py-3 bg-aa-blue text-white rounded-xl font-bold disabled:opacity-40">Créer</button>
</div>
)}
{postes.map((p) => (
<div key={p.id} className={`bg-white rounded-2xl p-4 shadow-sm ${!p.actif ? 'border-2 border-dashed border-gray-300' : ''}`}>
<div className="flex items-start justify-between gap-2">
@ -196,123 +165,58 @@ export default function GererPostes() {
<p className="font-bold text-gray-800">{CAT_ICON[p.categorie] || '⚪'} {p.nom}</p>
{p.description && <p className="text-xs text-gray-500 mt-1">{p.description}</p>}
{p.informations && <p className="text-xs text-gray-400 mt-1 italic">{p.informations}</p>}
{/* Tâches associées */}
{p.taches && p.taches.length > 0 && (
<div className="mt-2 space-y-1">
{p.taches.map((t) => (
<div key={t.id} className="flex items-center justify-between bg-gray-50 rounded-lg px-2 py-1">
<span className="text-xs text-gray-600">🔧 {t.nom}</span>
{p.actif && (
<button onClick={() => desaffecterTache(p.id, t.id)}
className="text-xs text-red-400 active:text-red-600">✕</button>
)}
</div>
))}
</div>
)}
</div>
<div className="flex flex-col gap-1">
{p.actif ? (
<button onClick={() => abolir(p.id, p.nom)}
className="text-xs text-red-500 font-medium px-2 py-1 rounded-lg active:bg-red-50">Abolir</button>
) : (
<>
<button onClick={() => reactiver(p.id)}
className="text-xs text-green-600 font-medium px-2 py-1 rounded-lg active:bg-green-50">Réactiver</button>
<button onClick={() => supprimerPermanent(p.id, p.nom)}
className="text-xs text-red-500 font-medium px-2 py-1 rounded-lg active:bg-red-50">Supprimer</button>
</>
)}
</div>
{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>
)}
</div>
</div>
))}
</div>
)}
{/* ── Tâches ── */}
{tab === 'taches' && (
{tab === 'acces' && (
<div className="space-y-3">
<button onClick={() => setShowTacheForm(!showTacheForm)}
className="w-full py-3 bg-green-50 text-green-700 rounded-xl font-bold border border-green-200 active:scale-[0.97]">
+ Créer une tâche
</button>
{showTacheForm && (
<div className="bg-white rounded-2xl p-4 shadow-sm space-y-3">
<input type="text" value={tacheNom} onChange={(e) => setTacheNom(e.target.value)}
placeholder="Nom de la tâche" className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue" autoFocus />
<textarea value={tacheInfo} onChange={(e) => setTacheInfo(e.target.value)}
placeholder="Informations" rows={2}
className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue resize-none" />
<button onClick={creerTache} disabled={!tacheNom.trim()}
className="w-full py-3 bg-aa-blue text-white rounded-xl font-bold disabled:opacity-40">Créer</button>
</div>
)}
{/* Affecter tâche → poste */}
{taches.length > 0 && postesActifs.length > 0 && (
<div className="bg-white rounded-2xl p-4 shadow-sm space-y-3">
<p className="text-xs font-bold text-gray-400 uppercase tracking-wider">Affecter une tâche à un poste</p>
<select value={affectTacheId} onChange={(e) => setAffectTacheId(e.target.value)}
className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue text-sm">
<option value="">— Tâche —</option>
{taches.map((t) => <option key={t.id} value={t.id}>{t.nom}</option>)}
</select>
<select value={affectPosteId} onChange={(e) => setAffectPosteId(e.target.value)}
className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue text-sm">
<option value="">— Poste —</option>
{postesActifs.map((p) => <option key={p.id} value={p.id}>{CAT_ICON[p.categorie]} {p.nom}</option>)}
</select>
<button onClick={affecterTache} disabled={!affectPosteId || !affectTacheId}
className="w-full py-2 bg-purple-600 text-white rounded-xl font-bold disabled:opacity-40 text-sm">
🔧 Affecter
</button>
</div>
)}
{/* Liste des tâches */}
{taches.map((t) => (
<div key={t.id} className="bg-white rounded-2xl p-4 shadow-sm">
<div className="flex items-start justify-between gap-2">
<div className="flex-1">
<p className="font-bold text-gray-800">🔧 {t.nom}</p>
{t.informations && <p className="text-xs text-gray-500 mt-1">{t.informations}</p>}
{t.postes.length > 0 && (
<p className="text-xs text-purple-500 mt-1">→ {t.postes.join(', ')}</p>
)}
</div>
<button onClick={() => supprimerTache(t.id, t.nom)}
className="text-xs text-red-500 font-medium px-2 py-1 rounded-lg active:bg-red-50">Supprimer</button>
{postes.filter((p) => p.actif).map((p) => (
<div key={p.id} className="bg-white rounded-2xl p-4 shadow-sm">
<p className="font-bold text-gray-800 mb-3">{p.nom}</p>
<div className="grid grid-cols-2 gap-2">
{MODULES.map(([code, label]) => {
const actif = (modulesPostes[p.id] || []).includes(code);
return (
<button key={code} onClick={() => toggleModule(p.id, code)}
className={`p-2 rounded-xl text-xs font-bold border ${
actif ? 'bg-aa-blue text-white border-aa-blue' : 'bg-gray-50 text-gray-500 border-gray-200'
}`}>
{code} · {label}
</button>
);
})}
</div>
</div>
))}
</div>
)}
{/* ── Candidatures ── */}
{tab === 'candidatures' && (
candidatures.length === 0 ? (
<div className="text-center py-12">
<p className="text-4xl mb-2">🙋</p>
<p className="text-gray-500">Aucune candidature en attente</p>
</div>
<div className="text-center py-12"><p className="text-gray-500">Aucune candidature en attente</p></div>
) : (
<div className="space-y-3">
{candidatures.map((c) => (
<div key={c.id} className="bg-white rounded-2xl p-4 shadow-sm">
<p className="font-bold text-gray-800">{c.membre_prenom} → {c.poste_nom}</p>
{c.motivation && <p className="text-sm text-gray-500 mt-1 italic">«{c.motivation}»</p>}
<p className="text-xs text-gray-400 mt-1">{new Date(c.cree_le).toLocaleDateString('fr-CA')}</p>
<div className="flex gap-2 mt-3">
<button onClick={() => traiterCandidature(c.id, 'acceptee')}
className="flex-1 py-2 bg-green-50 text-green-700 rounded-xl text-sm font-bold border border-green-200 active:scale-[0.97]">
✓ Accepter
</button>
className="flex-1 py-2 bg-green-50 text-green-700 rounded-xl text-sm font-bold border border-green-200">Accepter</button>
<button onClick={() => traiterCandidature(c.id, 'refusee')}
className="flex-1 py-2 bg-red-50 text-red-700 rounded-xl text-sm font-bold border border-red-200 active:scale-[0.97]">
✗ Refuser
</button>
className="flex-1 py-2 bg-red-50 text-red-700 rounded-xl text-sm font-bold border border-red-200">Refuser</button>
</div>
</div>
))}

View file

@ -0,0 +1,36 @@
import React, { useState } from 'react';
import PageHeader from '../components/PageHeader';
import AffecterPostes from './AffecterPostes';
import TerminerMandat from './TerminerMandat';
import PlanifierRotations from './PlanifierRotations';
export default function GestionMandats() {
const [tab, setTab] = useState('affecter');
const tabs = [
['affecter', 'Affecter'],
['terminer', 'Terminer'],
['rotations', 'Rotations'],
];
return (
<div className="min-h-screen bg-gray-100">
<PageHeader titre="Gestion des mandats" sousTitre="Affectations, fins de mandat, rotations" />
<div className="p-4 pb-0">
<div className="flex gap-1 overflow-x-auto">
{tabs.map(([key, label]) => (
<button key={key} onClick={() => setTab(key)}
className={`flex-1 py-2 rounded-xl text-xs font-bold transition whitespace-nowrap px-2 ${
tab === key ? 'bg-aa-blue text-white' : 'bg-white text-gray-500'
}`}>
{label}
</button>
))}
</div>
</div>
{tab === 'affecter' && <AffecterPostes compact />}
{tab === 'terminer' && <TerminerMandat compact />}
{tab === 'rotations' && <PlanifierRotations compact />}
</div>
);
}

View file

@ -66,7 +66,7 @@ export default function Impliquer() {
return (
<div className="min-h-screen bg-gray-100">
<PageHeader titre="S'impliquer" sousTitre="Postes vacants, candidatures, nominations" />
<PageHeader titre="Postes à pourvoir" sousTitre="Postes vacants, candidatures, nominations" />
<div className="p-4 space-y-4">
{msg && <div className="bg-green-50 text-green-700 p-3 rounded-xl text-center text-sm font-medium">{msg}</div>}

View file

@ -1,86 +0,0 @@
import React, { useState } from 'react';
import { api } from '../stores/api';
import PageHeader from '../components/PageHeader';
export default function InviterMembre() {
const [code, setCode] = useState(null);
const [expiration, setExpiration] = useState(null);
const [loading, setLoading] = useState(false);
const [copied, setCopied] = useState(false);
const generer = async () => {
setLoading(true);
try {
const data = await api.post('/invitations/');
setCode(data.code);
setExpiration(new Date(data.expire_le).toLocaleString('fr-CA'));
} catch (e) {
alert(e.message);
}
setLoading(false);
};
const copier = () => {
navigator.clipboard.writeText(code).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
};
const partagerSMS = () => {
const msg = `Rejoins le Groupe Méditation sur l'app ! Ton code d'invitation : ${code} (expire dans 48h)`;
window.open(`sms:?body=${encodeURIComponent(msg)}`);
};
return (
<div className="min-h-screen bg-gray-100">
<PageHeader titre="Inviter un membre" sousTitre="G1 — Code d'invitation 48h" />
<div className="p-4 space-y-4">
{!code ? (
<div className="text-center py-12">
<p className="text-5xl mb-4">➕</p>
<p className="text-gray-600 mb-2">Générer un code d'invitation</p>
<p className="text-gray-400 text-sm mb-6">Le code expire après 48 heures et ne peut être utilisé qu'une fois</p>
<button
onClick={generer}
disabled={loading}
className="px-8 py-4 bg-green-600 text-white rounded-2xl text-lg font-bold active:scale-[0.97] transition shadow-lg"
>
{loading ? '...' : 'Générer le code'}
</button>
</div>
) : (
<div className="space-y-4">
<div className="bg-white rounded-2xl p-6 shadow-sm text-center">
<p className="text-xs text-gray-400 mb-2">Code d'invitation</p>
<p className="text-3xl font-mono font-bold tracking-widest text-aa-blue">{code}</p>
<p className="text-xs text-gray-400 mt-3">Expire le {expiration}</p>
</div>
<button
onClick={copier}
className="w-full p-4 bg-aa-blue text-white rounded-2xl font-bold active:scale-[0.97] transition"
>
{copied ? '✓ Copié !' : '📋 Copier le code'}
</button>
<button
onClick={partagerSMS}
className="w-full p-4 bg-green-600 text-white rounded-2xl font-bold active:scale-[0.97] transition"
>
💬 Partager par SMS
</button>
<button
onClick={() => { setCode(null); setExpiration(null); }}
className="w-full p-3 text-gray-500 text-sm"
>
Générer un autre code
</button>
</div>
)}
</div>
</div>
);
}

View file

@ -1,173 +0,0 @@
import React, { useState, useEffect } from 'react';
import { api } from '../stores/api';
import PageHeader from '../components/PageHeader';
import SuccessCheck from '../components/SuccessCheck';
export default function JumelerParrain() {
const [membres, setMembres] = useState([]);
const [jumelages, setJumelages] = useState([]);
const [form, setForm] = useState({
parrain_id: '', filleul_id: '',
date_jumelage: new Date().toISOString().slice(0, 10),
note: '',
});
const [loading, setLoading] = useState(false);
const [success, setSuccess] = useState(false);
const [error, setError] = useState('');
const [showForm, setShowForm] = useState(false);
useEffect(() => {
api.get('/membres/actifs').then(setMembres).catch(() => []);
chargerJumelages();
}, []);
const chargerJumelages = async () => {
try {
const data = await api.get('/jumelages/');
setJumelages(data);
} catch { setJumelages([]); }
};
const update = (k, v) => setForm({ ...form, [k]: v });
const handleSubmit = async () => {
if (!form.parrain_id || !form.filleul_id) {
setError('Sélectionnez le parrain et le filleul');
return;
}
if (form.parrain_id === form.filleul_id) {
setError('Le parrain et le filleul doivent être différents');
return;
}
setLoading(true);
setError('');
try {
await api.post('/jumelages/', {
parrain_id: form.parrain_id,
filleul_id: form.filleul_id,
date_jumelage: form.date_jumelage,
note: form.note.trim() || null,
});
setSuccess(true);
} catch (e) {
setError(e.message);
setLoading(false);
}
};
const terminer = async (id) => {
try {
await api.patch(`/jumelages/${id}`, { statut: 'termine' });
chargerJumelages();
} catch (e) {
setError(e.message);
}
};
if (success) return <SuccessCheck message="Jumelage créé ✓" />;
const actifs = jumelages.filter((j) => j.statut === 'actif');
const termines = jumelages.filter((j) => j.statut === 'termine');
return (
<div className="min-h-screen bg-gray-100">
<PageHeader titre="Jumeler parrain" sousTitre="G7 — Parrainage confidentiel" />
<div className="p-4 space-y-4">
<div className="bg-yellow-50 rounded-2xl p-4">
<p className="text-xs text-yellow-700">
🔒 Ces informations sont confidentielles et accessibles uniquement à l'exécutif.
</p>
</div>
<button onClick={() => setShowForm(!showForm)}
className="w-full p-3 bg-yellow-100 text-yellow-700 rounded-2xl text-sm font-bold active:bg-yellow-200">
{showForm ? '✕ Annuler' : '🤝 Nouveau jumelage'}
</button>
{showForm && (
<div className="bg-white rounded-2xl p-4 shadow-sm space-y-3">
<div>
<label className="text-xs text-gray-400 block mb-1">Parrain</label>
<select value={form.parrain_id} onChange={(e) => update('parrain_id', e.target.value)}
className="w-full p-3 bg-gray-50 rounded-xl text-lg border-0 focus:ring-2 focus:ring-yellow-400">
<option value="">Sélectionner le parrain</option>
{membres.map((m) => <option key={m.id} value={m.id}>{m.prenom}</option>)}
</select>
</div>
<div>
<label className="text-xs text-gray-400 block mb-1">Filleul</label>
<select value={form.filleul_id} onChange={(e) => update('filleul_id', e.target.value)}
className="w-full p-3 bg-gray-50 rounded-xl text-lg border-0 focus:ring-2 focus:ring-yellow-400">
<option value="">Sélectionner le filleul</option>
{membres.map((m) => <option key={m.id} value={m.id}>{m.prenom}</option>)}
</select>
</div>
<div>
<label className="text-xs text-gray-400 block mb-1">Date</label>
<input type="date" value={form.date_jumelage}
onChange={(e) => update('date_jumelage', e.target.value)}
className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-yellow-400" />
</div>
<div>
<label className="text-xs text-gray-400 block mb-1">Note (optionnel)</label>
<input type="text" placeholder="Circonstances, observations..."
value={form.note} onChange={(e) => update('note', e.target.value)}
className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-yellow-400" />
</div>
{error && <p className="text-red-500 text-xs">{error}</p>}
<button onClick={handleSubmit} disabled={loading || !form.parrain_id || !form.filleul_id}
className="w-full p-4 bg-aa-gold text-white rounded-xl text-lg font-bold disabled:opacity-40 active:scale-[0.97] transition">
{loading ? '...' : '🤝 Créer le jumelage'}
</button>
</div>
)}
{/* Jumelages actifs */}
{actifs.length > 0 && (
<div className="space-y-2">
<h2 className="text-xs font-bold text-gray-400 uppercase tracking-wider px-1">Actifs</h2>
{actifs.map((j) => (
<div key={j.id} className="bg-white rounded-2xl p-4 shadow-sm border-2 border-yellow-200">
<div className="flex items-center gap-3">
<span className="text-2xl">🤝</span>
<div className="flex-1">
<p className="font-bold text-sm">{j.parrain_prenom} → {j.filleul_prenom}</p>
<p className="text-xs text-gray-400">Depuis {j.date_jumelage}</p>
{j.note && <p className="text-xs text-gray-300 mt-1">{j.note}</p>}
</div>
<button onClick={() => terminer(j.id)}
className="text-xs bg-red-50 text-red-500 px-3 py-1 rounded-lg font-bold">
Terminer
</button>
</div>
</div>
))}
</div>
)}
{/* Jumelages terminés */}
{termines.length > 0 && (
<div className="space-y-2">
<h2 className="text-xs font-bold text-gray-400 uppercase tracking-wider px-1">Terminés</h2>
{termines.map((j) => (
<div key={j.id} className="bg-gray-50 rounded-2xl p-3 border border-gray-200">
<p className="text-sm text-gray-500">{j.parrain_prenom} → {j.filleul_prenom}</p>
<p className="text-xs text-gray-300">{j.date_jumelage}</p>
</div>
))}
</div>
)}
{jumelages.length === 0 && !showForm && (
<div className="text-center py-12">
<span className="text-4xl block mb-2">🤝</span>
<p className="text-gray-400">Aucun jumelage enregistré</p>
</div>
)}
</div>
</div>
);
}

View file

@ -8,7 +8,7 @@ const JOURS_FR = ['Dimanche', 'Lundi', 'Mardi', 'Mercredi', 'Jeudi', 'Vendredi',
// Postes typiques de rotation hebdomadaire
const POSTES_ROTATION = ['Animateur(trice)', 'Responsable café', 'Responsable accueil', 'Responsable clés'];
export default function PlanifierRotations() {
export default function PlanifierRotations({ compact = false }) {
const [rotations, setRotations] = useState([]);
const [postes, setPostes] = useState([]);
const [membres, setMembres] = useState([]);
@ -66,8 +66,8 @@ export default function PlanifierRotations() {
});
return (
<div className="min-h-screen bg-gray-100">
<PageHeader titre="Planifier les rotations" sousTitre="G5 — Calendrier hebdomadaire" />
<div className={compact ? '' : 'min-h-screen bg-gray-100'}>
{!compact && <PageHeader titre="Planifier les rotations" sousTitre="Calendrier hebdomadaire" />}
<div className="p-4 space-y-4">
{/* Bouton ajouter */}

View file

@ -27,7 +27,7 @@ export default function Rapports() {
const telecharger = async (path, key, filename) => {
setGenerating(key);
try {
const token = localStorage.getItem('gm_token');
const token = api.getToken();
const res = await fetch(`/api/rapports/${path}`, {
headers: { Authorization: `Bearer ${token}` },
});

View file

@ -3,7 +3,7 @@ import { api } from '../stores/api';
import PageHeader from '../components/PageHeader';
import SuccessCheck from '../components/SuccessCheck';
export default function TerminerMandat() {
export default function TerminerMandat({ compact = false }) {
const [affectations, setAffectations] = useState([]);
const [selected, setSelected] = useState(null);
const [noteTransition, setNoteTransition] = useState('');
@ -86,8 +86,8 @@ export default function TerminerMandat() {
}
return (
<div className="min-h-screen bg-gray-100">
<PageHeader titre="Terminer un mandat" sousTitre="G4 — Transition manuelle" />
<div className={compact ? '' : 'min-h-screen bg-gray-100'}>
{!compact && <PageHeader titre="Terminer un mandat" sousTitre="Transition manuelle" />}
<div className="p-4 space-y-2">
{loading ? (
<p className="text-center text-gray-400 py-8">Chargement...</p>

View file

@ -11,33 +11,52 @@ const STATUT_COL = {
export default function Tresorerie() {
const [soldes, setSoldes] = useState(null);
const [collectes, setCollectes] = useState([]);
const [contributions, setContributions] = useState([]);
const [operations, setOperations] = useState([]);
const [reserves, setReserves] = useState([]);
const [registre, setRegistre] = useState([]);
const [tab, setTab] = useState('soldes');
const [msg, setMsg] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(true);
const [registreMois, setRegistreMois] = useState('');
const [registreAnnee, setRegistreAnnee] = useState(String(new Date().getFullYear()));
const [registreType, setRegistreType] = useState('');
// Formulaires
const [depotMontant, setDepotMontant] = useState('');
const [depotDate, setDepotDate] = useState(new Date().toISOString().slice(0, 10));
const [depotNote, setDepotNote] = useState('');
const [depotType, setDepotType] = useState('depot'); // depot | retrait | releve
const [reserveId, setReserveId] = useState(null);
const [reserveNom, setReserveNom] = useState('');
const [reserveMontant, setReserveMontant] = useState('');
const [reserveNote, setReserveNote] = useState('');
const moisActuel = new Date().getMonth() + 1;
const anneeActuelle = new Date().getFullYear();
const registreQuery = () => {
const params = new URLSearchParams();
if (registreMois) params.set('mois', registreMois);
if (registreAnnee) params.set('annee', registreAnnee);
if (registreType) params.set('type_transaction', registreType);
const query = params.toString();
return `/collectes/registre${query ? `?${query}` : ''}`;
};
const charger = async () => {
try {
const [s, c, o, r] = await Promise.all([
const [s, c, contribs, o, res, r] = await Promise.all([
api.get('/collectes/soldes'),
api.get('/collectes/?limite=20'),
api.get('/contributions/?limite=50'),
api.get('/banque/'),
api.get(`/collectes/registre?mois=${moisActuel}&annee=${anneeActuelle}`),
api.get('/banque/reserves'),
api.get(registreQuery()),
]);
setSoldes(s);
setCollectes(c);
setContributions(contribs);
setOperations(o);
setReserves(res);
setRegistre(r);
} catch (e) { setError(e.message); }
setLoading(false);
@ -45,6 +64,13 @@ export default function Tresorerie() {
useEffect(() => { charger(); }, []);
const chargerRegistre = async () => {
setError('');
try {
setRegistre(await api.get(registreQuery()));
} catch (e) { setError(e.message); }
};
const flash = (m) => { setMsg(m); setTimeout(() => setMsg(''), 2500); };
const accuserReception = async (id) => {
@ -65,6 +91,15 @@ export default function Tresorerie() {
} catch (e) { setError(e.message); }
};
const confirmerContribution = async (id) => {
setError('');
try {
await api.post(`/contributions/${id}/confirmer-debit`);
flash('Contribution débitée ✓');
await charger();
} catch (e) { setError(e.message); }
};
const enregistrerOperation = async () => {
if (!depotMontant || parseFloat(depotMontant) <= 0) { setError('Montant invalide'); return; }
setError('');
@ -82,10 +117,74 @@ export default function Tresorerie() {
} catch (e) { setError(e.message); }
};
const resetReserveForm = () => {
setReserveId(null);
setReserveNom('');
setReserveMontant('');
setReserveNote('');
};
const editerReserve = (reserve) => {
setReserveId(reserve.id);
setReserveNom(reserve.nom);
setReserveMontant(String(reserve.montant));
setReserveNote(reserve.note || '');
setTab('reserves');
};
const enregistrerReserve = async () => {
const montant = parseFloat(reserveMontant);
if (!reserveNom.trim()) { setError('Nom de réserve requis'); return; }
if (Number.isNaN(montant) || montant < 0) { setError('Montant de réserve invalide'); return; }
setError('');
try {
const payload = {
nom: reserveNom.trim(),
montant,
note: reserveNote.trim() || null,
};
if (reserveId) {
await api.patch(`/banque/reserves/${reserveId}`, payload);
flash('Réserve modifiée ✓');
} else {
await api.post('/banque/reserves', payload);
flash('Réserve créée ✓');
}
resetReserveForm();
await charger();
} catch (e) { setError(e.message); }
};
const supprimerReserve = async (id) => {
if (!window.confirm('Supprimer cette réserve ?')) return;
setError('');
try {
await api.delete(`/banque/reserves/${id}`);
flash('Réserve supprimée ✓');
if (reserveId === id) resetReserveForm();
await charger();
} catch (e) { setError(e.message); }
};
const aRecevoir = collectes.filter((c) => c.statut !== 'recu');
const contributionsADebiter = contributions.filter((c) => c.engagee && !c.debitee);
const totalReserveForm = parseFloat(reserveMontant) || 0;
const reservesSansEdition = reserves
.filter((r) => r.id !== reserveId)
.reduce((total, r) => total + parseFloat(r.montant || 0), 0);
const reserveDepasseSolde = soldes && reservesSansEdition + totalReserveForm > soldes.solde_banque;
const MOIS_NOMS = ['', 'janvier', 'février', 'mars', 'avril', 'mai', 'juin',
'juillet', 'août', 'septembre', 'octobre', 'novembre', 'décembre'];
const TYPES_REGISTRE = [
['', 'Tous les types'],
['collecte', 'Collectes'],
['vente_litterature', 'Ventes littérature'],
['depense', 'Dépenses'],
['contribution', 'Contributions'],
['banque_depot', 'Dépôts bancaires'],
['banque_retrait', 'Retraits bancaires'],
];
if (loading) return <div className="flex items-center justify-center min-h-screen"><p className="text-gray-400">Chargement...</p></div>;
@ -101,8 +200,9 @@ export default function Tresorerie() {
<div className="flex gap-1 overflow-x-auto">
{[
['soldes', '💰 Soldes'],
['receptions', `📥 À recevoir (${aRecevoir.length + (soldes?.ventes_non_remises_details?.length || 0)})`],
['receptions', `📥 À traiter (${aRecevoir.length + (soldes?.ventes_non_remises_details?.length || 0) + contributionsADebiter.length})`],
['registre', '📒 Registre'],
['reserves', `🧾 Réserves (${reserves.length})`],
['banque', '🏦 Opération'],
].map(([t, label]) => (
<button key={t} onClick={() => { setTab(t); setError(''); }}
@ -145,17 +245,25 @@ export default function Tresorerie() {
{/* Réserves */}
<div className="bg-amber-50 rounded-2xl p-4 border border-amber-200">
<p className="font-bold text-amber-700 text-sm mb-2">Réserves ({soldes.total_reserves.toFixed(2)} $)</p>
<div className="space-y-1 text-xs text-amber-600">
<div className="flex justify-between">
<span>Réserve prudente</span>
<span className="font-bold">{soldes.reserve_prudente.toFixed(2)} $</span>
</div>
<div className="flex justify-between">
<span>Réserve anniversaire (+20$/mois)</span>
<span className="font-bold">{soldes.reserve_anniversaire.toFixed(2)} $</span>
</div>
<div className="flex items-center justify-between mb-2">
<p className="font-bold text-amber-700 text-sm">Réserves ({soldes.total_reserves.toFixed(2)} $)</p>
<button onClick={() => setTab('reserves')}
className="text-xs font-bold text-amber-700 bg-amber-100 px-3 py-1 rounded-lg">
Gérer
</button>
</div>
{soldes.reserves_details?.length > 0 ? (
<div className="space-y-1 text-xs text-amber-700">
{soldes.reserves_details.map((r) => (
<div key={r.id} className="flex justify-between gap-3">
<span className="truncate">{r.nom}</span>
<span className="font-bold whitespace-nowrap">{r.montant.toFixed(2)} $</span>
</div>
))}
</div>
) : (
<p className="text-xs text-amber-600">Aucune réserve enregistrée.</p>
)}
</div>
{/* Engagements */}
@ -184,9 +292,125 @@ export default function Tresorerie() {
</div>
)}
{/* ── Réceptions (collectes + ventes littérature) ── */}
{/* ── Réserves ── */}
{tab === 'reserves' && soldes && (
<div className="space-y-4">
<div className="bg-white rounded-2xl p-4 shadow-sm">
<div className="grid grid-cols-2 gap-3 mb-4">
<div className="p-3 bg-blue-50 rounded-xl">
<p className="text-xs text-blue-600">Solde banque</p>
<p className="text-xl font-bold text-blue-700">{soldes.solde_banque.toFixed(2)} $</p>
</div>
<div className="p-3 bg-amber-50 rounded-xl">
<p className="text-xs text-amber-600">Réserves</p>
<p className="text-xl font-bold text-amber-700">{soldes.total_reserves.toFixed(2)} $</p>
</div>
</div>
<div className="space-y-3">
<div>
<label className="text-xs text-gray-400 block mb-1">Nom</label>
<input type="text" value={reserveNom} onChange={(e) => setReserveNom(e.target.value)}
placeholder="Ex: Prudence, anniversaire, loyer"
className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue" />
</div>
<div>
<label className="text-xs text-gray-400 block mb-1">Montant réservé</label>
<input type="number" inputMode="decimal" step="0.01" min="0" value={reserveMontant}
onChange={(e) => setReserveMontant(e.target.value)}
placeholder="0.00"
className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue" />
</div>
<div>
<label className="text-xs text-gray-400 block mb-1">Note (optionnel)</label>
<input type="text" value={reserveNote} onChange={(e) => setReserveNote(e.target.value)}
placeholder="But de la réserve"
className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue" />
</div>
{reserveDepasseSolde && (
<p className="text-xs text-red-600 bg-red-50 p-2 rounded-lg">
Le total des réserves ne peut pas dépasser le solde en banque.
</p>
)}
<div className="flex gap-2">
<button onClick={enregistrerReserve} disabled={reserveDepasseSolde}
className="flex-1 p-3 bg-aa-blue text-white rounded-xl text-sm font-bold disabled:opacity-40 active:scale-[0.97] transition">
{reserveId ? 'Modifier' : 'Créer'}
</button>
{reserveId && (
<button onClick={resetReserveForm}
className="px-4 py-3 bg-gray-100 text-gray-600 rounded-xl text-sm font-bold">
Annuler
</button>
)}
</div>
</div>
</div>
{reserves.length === 0 ? (
<div className="text-center py-10">
<p className="text-gray-500">Aucune réserve</p>
</div>
) : (
<div className="space-y-3">
{reserves.map((r) => (
<div key={r.id} className="bg-white rounded-2xl p-4 shadow-sm">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="font-bold text-gray-800 truncate">{r.nom}</p>
{r.note && <p className="text-xs text-gray-400 mt-1">{r.note}</p>}
</div>
<p className="font-bold text-amber-700 whitespace-nowrap">{parseFloat(r.montant).toFixed(2)} $</p>
</div>
<div className="flex gap-2 mt-3">
<button onClick={() => editerReserve(r)}
className="flex-1 py-2 bg-blue-50 text-blue-700 rounded-xl text-sm font-bold border border-blue-100">
Modifier
</button>
<button onClick={() => supprimerReserve(r.id)}
className="flex-1 py-2 bg-red-50 text-red-700 rounded-xl text-sm font-bold border border-red-100">
Supprimer
</button>
</div>
</div>
))}
</div>
)}
</div>
)}
{/* ── Réceptions et débits à confirmer ── */}
{tab === 'receptions' && (
<div className="space-y-4">
{/* Contributions à débiter */}
{contributionsADebiter.length > 0 && (
<div>
<h3 className="text-xs font-bold text-gray-400 uppercase tracking-wider mb-2 px-1">
Contributions à débiter
</h3>
<div className="space-y-3">
{contributionsADebiter.map((c) => (
<div key={c.id} className="bg-white rounded-2xl p-4 shadow-sm">
<div className="flex items-start justify-between gap-2">
<div className="flex-1">
<p className="font-bold text-gray-800">{c.destination_label}</p>
<p className="text-lg font-bold text-orange-600">{parseFloat(c.montant).toFixed(2)} $</p>
<p className="text-xs text-gray-400">{c.date_envoi} · {c.methode} · Saisi par {c.saisi_par_prenom || '—'}</p>
</div>
<span className="text-xs px-2 py-1 rounded-full font-medium bg-orange-100 text-orange-700">À débiter</span>
</div>
<button onClick={() => confirmerContribution(c.id)}
className="w-full mt-3 py-2 bg-green-50 text-green-700 rounded-xl text-sm font-bold border border-green-200 active:scale-[0.97] transition">
✓ Confirmer le débit
</button>
</div>
))}
</div>
</div>
)}
{/* Collectes à recevoir */}
{aRecevoir.length > 0 && (
<div>
@ -239,7 +463,7 @@ export default function Tresorerie() {
</div>
)}
{aRecevoir.length === 0 && (!soldes?.ventes_non_remises_details || soldes.ventes_non_remises_details.length === 0) && (
{aRecevoir.length === 0 && contributionsADebiter.length === 0 && (!soldes?.ventes_non_remises_details || soldes.ventes_non_remises_details.length === 0) && (
<div className="text-center py-12">
<p className="text-4xl mb-2">✅</p>
<p className="text-gray-500">Tout est reçu</p>
@ -251,15 +475,33 @@ export default function Tresorerie() {
{/* ── Registre des transactions ── */}
{tab === 'registre' && (
<div className="space-y-3">
<div className="bg-white rounded-2xl p-3 shadow-sm">
<p className="text-xs font-bold text-gray-400 uppercase tracking-wider">
Registre — {MOIS_NOMS[moisActuel]} {anneeActuelle}
</p>
<div className="bg-white rounded-2xl p-4 shadow-sm space-y-3">
<p className="text-xs font-bold text-gray-400 uppercase tracking-wider">Filtres du registre</p>
<div className="grid grid-cols-2 gap-2">
<select value={registreMois} onChange={(e) => setRegistreMois(e.target.value)}
className="w-full p-3 bg-gray-50 rounded-xl text-sm border-0 focus:ring-2 focus:ring-aa-blue">
<option value="">Tous les mois</option>
{MOIS_NOMS.slice(1).map((nom, index) => (
<option key={nom} value={index + 1}>{nom.charAt(0).toUpperCase() + nom.slice(1)}</option>
))}
</select>
<input type="number" inputMode="numeric" placeholder="Toutes années"
value={registreAnnee} onChange={(e) => setRegistreAnnee(e.target.value.replace(/\D/g, '').slice(0, 4))}
className="w-full p-3 bg-gray-50 rounded-xl text-sm border-0 focus:ring-2 focus:ring-aa-blue" />
</div>
<select value={registreType} onChange={(e) => setRegistreType(e.target.value)}
className="w-full p-3 bg-gray-50 rounded-xl text-sm border-0 focus:ring-2 focus:ring-aa-blue">
{TYPES_REGISTRE.map(([val, label]) => <option key={val || 'all'} value={val}>{label}</option>)}
</select>
<button onClick={chargerRegistre}
className="w-full p-3 bg-aa-blue text-white rounded-xl text-sm font-bold active:scale-[0.97] transition">
Appliquer
</button>
</div>
{registre.length === 0 ? (
<div className="text-center py-12">
<p className="text-4xl mb-2">📒</p>
<p className="text-gray-500">Aucune transaction ce mois-ci</p>
<p className="text-gray-500">Aucune transaction pour ces filtres</p>
</div>
) : (
<>
@ -274,7 +516,7 @@ export default function Tresorerie() {
</div>
))}
<div className="bg-gray-50 rounded-xl p-3 flex justify-between text-sm font-bold">
<span className="text-gray-600">Total du mois</span>
<span className="text-gray-600">Total affiché</span>
<div className="flex gap-4">
<span className="text-green-600">+{registre.reduce((s, t) => s + t.entree, 0).toFixed(2)} $</span>
<span className="text-red-600">-{registre.reduce((s, t) => s + t.sortie, 0).toFixed(2)} $</span>
@ -307,6 +549,16 @@ export default function Tresorerie() {
Le relevé enregistre le solde réel de votre compte bancaire tel qu'indiqué sur le relevé mensuel.
</p>
)}
{depotType === 'depot' && (
<p className="text-xs text-green-600 mt-2 bg-green-50 p-2 rounded-lg">
Dépôt de l'encaisse vers la banque. Encaisse disponible : {soldes?.encaisse?.toFixed(2) || '0.00'} $.
</p>
)}
{depotType === 'retrait' && (
<p className="text-xs text-orange-600 mt-2 bg-orange-50 p-2 rounded-lg">
Retrait de la banque vers l'encaisse.
</p>
)}
</div>
<div className="bg-white rounded-2xl p-4 shadow-sm">

View file

@ -5,15 +5,20 @@ const BASE = '/api';
class ApiClient {
constructor() {
this.token = localStorage.getItem('gm_token');
this.token = sessionStorage.getItem('gm_token') || localStorage.getItem('gm_token');
}
setToken(token) {
getToken() {
return this.token;
}
setToken(token, persist = false) {
this.token = token;
sessionStorage.removeItem('gm_token');
localStorage.removeItem('gm_token');
if (token) {
localStorage.setItem('gm_token', token);
} else {
localStorage.removeItem('gm_token');
const storage = persist ? localStorage : sessionStorage;
storage.setItem('gm_token', token);
}
}
@ -27,9 +32,12 @@ class ApiClient {
const res = await fetch(`${BASE}${path}`, opts);
if (res.status === 401) {
this.setToken(null);
window.location.reload();
throw new Error('Session expirée');
if (!path.startsWith('/auth/')) {
this.setToken(null);
localStorage.removeItem('gm_page');
window.dispatchEvent(new CustomEvent('gm:unauthorized'));
throw new Error('Session expirée');
}
}
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: 'Erreur serveur' }));
@ -49,6 +57,7 @@ class ApiClient {
get(path) { return this.request('GET', path); }
post(path, body) { return this.request('POST', path, body); }
patch(path, body) { return this.request('PATCH', path, body); }
delete(path) { return this.request('DELETE', path); }
}
export const api = new ApiClient();

View file

@ -3,7 +3,7 @@ import { api } from './api';
export const useStore = create((set, get) => ({
// Auth
token: localStorage.getItem('gm_token'),
token: api.getToken(),
membre: null,
groupeId: localStorage.getItem('gm_groupe_id'),
groupes: [],
@ -41,7 +41,7 @@ export const useStore = create((set, get) => ({
const data = await api.post('/auth/connexion', {
courriel, pin, groupe_id: groupeId, se_souvenir: seSouvenir,
});
api.setToken(data.access_token);
api.setToken(data.access_token, seSouvenir);
localStorage.setItem('gm_groupe_id', groupeId);
if (seSouvenir) {
localStorage.setItem('gm_courriel', courriel);
@ -71,7 +71,7 @@ export const useStore = create((set, get) => ({
date_abstinence: data.date_abstinence || null,
};
const res = await api.post('/auth/inscription', payload);
api.setToken(res.access_token);
api.setToken(res.access_token, false);
localStorage.setItem('gm_groupe_id', data.groupe_id || '');
set({
token: res.access_token,