feat: automate deployment and update group workflows
This commit is contained in:
parent
2245f26492
commit
5e5c38fb21
67 changed files with 8991 additions and 1031 deletions
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
node_modules/
|
||||
dist/
|
||||
*.retry
|
||||
ansible/group_vars/vault.yml
|
||||
15
README.md
15
README.md
|
|
@ -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
15
ansible.cfg
Normal 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
21
ansible/backup-now.yml
Normal 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] }}"
|
||||
|
||||
55
ansible/group_vars/all.yml
Normal file
55
ansible/group_vars/all.yml
Normal 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
|
||||
|
||||
15
ansible/group_vars/vault.yml.example
Normal file
15
ansible/group_vars/vault.yml.example
Normal 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"
|
||||
|
||||
9
ansible/inventory/hosts.yml
Normal file
9
ansible/inventory/hosts.yml
Normal 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
7
ansible/requirements.yml
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
---
|
||||
collections:
|
||||
- name: ansible.posix
|
||||
version: ">=1.5.0"
|
||||
- name: community.postgresql
|
||||
version: ">=3.4.0"
|
||||
|
||||
10
ansible/roles/app/handlers/main.yml
Normal file
10
ansible/roles/app/handlers/main.yml
Normal 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
|
||||
|
||||
196
ansible/roles/app/tasks/main.yml
Normal file
196
ansible/roles/app/tasks/main.yml
Normal 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
|
||||
6
ansible/roles/app/templates/env.j2
Normal file
6
ansible/roles/app/templates/env.j2
Normal 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 }}
|
||||
|
||||
18
ansible/roles/app/templates/groupe-meditation.service.j2
Normal file
18
ansible/roles/app/templates/groupe-meditation.service.j2
Normal 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
|
||||
|
||||
18
ansible/roles/backup/tasks/main.yml
Normal file
18
ansible/roles/backup/tasks/main.yml
Normal 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
|
||||
|
||||
18
ansible/roles/backup/templates/groupe-meditation-backup.j2
Normal file
18
ansible/roles/backup/templates/groupe-meditation-backup.j2
Normal 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"
|
||||
|
||||
17
ansible/roles/certbot/tasks/main.yml
Normal file
17
ansible/roles/certbot/tasks/main.yml
Normal 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"
|
||||
|
||||
50
ansible/roles/common/tasks/main.yml
Normal file
50
ansible/roles/common/tasks/main.yml
Normal 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
|
||||
6
ansible/roles/nginx/handlers/main.yml
Normal file
6
ansible/roles/nginx/handlers/main.yml
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
---
|
||||
- name: Recharger nginx
|
||||
ansible.builtin.service:
|
||||
name: nginx
|
||||
state: reloaded
|
||||
|
||||
39
ansible/roles/nginx/tasks/main.yml
Normal file
39
ansible/roles/nginx/tasks/main.yml
Normal 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
|
||||
|
||||
37
ansible/roles/nginx/templates/groupe-meditation.conf.j2
Normal file
37
ansible/roles/nginx/templates/groupe-meditation.conf.j2
Normal 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;
|
||||
}
|
||||
|
||||
51
ansible/roles/postgresql/tasks/main.yml
Normal file
51
ansible/roles/postgresql/tasks/main.yml
Normal 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
38
ansible/site.yml
Normal 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
42
ansible/verify.yml
Normal 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."
|
||||
|
||||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
16
backend/app/models/poste_module.py
Normal file
16
backend/app/models/poste_module.py
Normal 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))
|
||||
|
||||
19
backend/app/models/reserve.py
Normal file
19
backend/app/models/reserve.py
Normal 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))
|
||||
|
||||
|
|
@ -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)
|
||||
)
|
||||
|
|
@ -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,6 +107,17 @@ async def accueil(
|
|||
# Modules de saisie : collecte pour tous + modules par poste
|
||||
modules_saisie = list(MODULES_SAISIE_TOUS)
|
||||
codes_vus = {"S1", "S5"}
|
||||
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:
|
||||
|
|
@ -109,6 +125,13 @@ async def accueil(
|
|||
codes_vus.add(m.code)
|
||||
|
||||
# Modules de gestion : seulement si exécutif
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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 ''}",
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
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(),
|
||||
)
|
||||
result = await db.execute(
|
||||
select(Proposition).where(
|
||||
Proposition.id == req.resolution_id,
|
||||
Proposition.groupe_id == membre.groupe_id,
|
||||
Proposition.statut == "adoptee",
|
||||
)
|
||||
)
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
122
docs/ANSIBLE.md
Normal 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
426
docs/UTILISATEURS.md
Normal 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
6485
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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 />;
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
<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>
|
||||
) : (
|
||||
<select value={form.resolution_id} onChange={(e) => update('resolution_id', e.target.value)}
|
||||
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]) => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -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={() => abolir(p.id, p.nom)} className="text-xs text-red-500 font-medium px-2 py-1">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 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
|
||||
{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>
|
||||
|
||||
{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>
|
||||
);
|
||||
})}
|
||||
</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>
|
||||
))}
|
||||
|
|
|
|||
36
frontend/src/pages/GestionMandats.jsx
Normal file
36
frontend/src/pages/GestionMandats.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -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>}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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}` },
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 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>
|
||||
<div className="flex justify-between">
|
||||
<span>Réserve anniversaire (+20$/mois)</span>
|
||||
<span className="font-bold">{soldes.reserve_anniversaire.toFixed(2)} $</span>
|
||||
{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">
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
if (token) {
|
||||
localStorage.setItem('gm_token', token);
|
||||
} else {
|
||||
sessionStorage.removeItem('gm_token');
|
||||
localStorage.removeItem('gm_token');
|
||||
if (token) {
|
||||
const storage = persist ? localStorage : sessionStorage;
|
||||
storage.setItem('gm_token', token);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -27,10 +32,13 @@ class ApiClient {
|
|||
|
||||
const res = await fetch(`${BASE}${path}`, opts);
|
||||
if (res.status === 401) {
|
||||
if (!path.startsWith('/auth/')) {
|
||||
this.setToken(null);
|
||||
window.location.reload();
|
||||
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' }));
|
||||
const detail = err.detail;
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue