Ajouter gestion sysadmin des groupes

- retirer les actions BD du profil

- ajouter module Gestion des groupes

- neutraliser le titre de la page de selection
This commit is contained in:
Daniel Allaire 2026-05-31 22:10:09 -04:00
parent 0f69e80c77
commit c4c7dc7b64
7 changed files with 194 additions and 54 deletions

View file

@ -68,6 +68,7 @@ MODULES_GESTION = [
ModuleTuile(code="G12", nom="Gestion des postes", description="Postes, candidatures, accès", icone="📋", couleur="purple"),
ModuleTuile(code="G13", nom="Journal", description="Changements enregistrés", icone="🧾", couleur="gray"),
]
MODULE_SYSADMIN = ModuleTuile(code="G14", nom="Gestion des groupes", description="Groupes et démonstration", icone="🏷️", couleur="indigo")
for _modules in [MODULES_SAISIE_TOUS, MODULES_GESTION, *MODULES_SAISIE.values()]:
for _m in _modules:
@ -104,7 +105,7 @@ async def accueil(
est_executif=True,
modules_consultation=MODULES_CONSULTATION,
modules_saisie=modules_saisie_sysadmin,
modules_gestion=MODULES_GESTION,
modules_gestion=[MODULE_SYSADMIN, *MODULES_GESTION],
groupe=groupe_info,
)

View file

@ -1,10 +1,15 @@
"""Routes d'administration reservees au sysadmin."""
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import func, select
from app.core.database import get_db
from app.core.security import get_current_membre, is_sysadmin
from app.services.admin_database import initialiser_demo, remettre_a_zero
from app.models.groupe import Groupe
from app.models.membre import Membre
from app.models.reunion import Reunion
from app.models.historique_modification import HistoriqueModification
router = APIRouter(prefix="/admin", tags=["Administration"])
@ -29,3 +34,38 @@ async def demo_database(
db: AsyncSession = Depends(get_db),
):
return await initialiser_demo(db)
@router.get("/groupes")
async def liste_groupes_admin(
_sysadmin=Depends(require_sysadmin),
db: AsyncSession = Depends(get_db),
):
groupes = (await db.execute(select(Groupe).order_by(Groupe.nom))).scalars().all()
out = []
for groupe in groupes:
nb_membres = (await db.execute(
select(func.count()).select_from(Membre).where(Membre.groupe_id == groupe.id)
)).scalar_one()
nb_reunions = (await db.execute(
select(func.count()).select_from(Reunion).where(Reunion.groupe_id == groupe.id)
)).scalar_one()
nb_traces = (await db.execute(
select(func.count()).select_from(HistoriqueModification).where(HistoriqueModification.groupe_id == groupe.id)
)).scalar_one()
out.append({
"id": str(groupe.id),
"nom": groupe.nom,
"district": groupe.district,
"region": groupe.region,
"adresse_local": groupe.adresse_local,
"jour_reunion": groupe.jour_reunion,
"heure_reunion": groupe.heure_reunion.strftime("%H:%M") if groupe.heure_reunion else None,
"assemblee_affaires_ordre": groupe.assemblee_affaires_ordre,
"format_reunion": groupe.format_reunion,
"cree_le": groupe.cree_le,
"membres": nb_membres,
"reunions": nb_reunions,
"traces": nb_traces,
})
return out

View file

@ -30,6 +30,7 @@ import ReunionHebdomadaire from './pages/ReunionHebdomadaire';
import AssembleeAffaires from './pages/AssembleeAffaires';
import AssembleeInvite from './pages/AssembleeInvite';
import JournalActions from './pages/JournalActions';
import GestionGroupes from './pages/GestionGroupes';
import PrincipesUtilisation from './pages/PrincipesUtilisation';
import ModePapier from './pages/ModePapier';
import BottomNav from './components/BottomNav';
@ -83,6 +84,7 @@ export default function App() {
case 'G11': pageActive = <GestionMandats />; break;
case 'G12': pageActive = <GererPostes />; break;
case 'G13': pageActive = <JournalActions />; break;
case 'G14': pageActive = <GestionGroupes />; break;
case 'M1': pageActive = <RedigerPV />; break;
case 'M2': pageActive = <Presences />; break;
case 'M3': pageActive = <RapportRSG />; break;

View file

@ -12,7 +12,7 @@ const COULEURS = {
gray: 'bg-gray-50 border-gray-200 text-gray-700',
};
const NAVIGABLE = ['C7', 'S1', 'S1T', 'S2', 'S3', 'S4', 'S5', 'C1', 'C2', 'C4', 'C5', 'C9', 'C11', 'C12', 'G2', 'G9', 'G11', 'G12', 'G13', 'M0', 'M1', 'M2', 'M3', 'M4', 'M5', 'M6'];
const NAVIGABLE = ['C7', 'S1', 'S1T', 'S2', 'S3', 'S4', 'S5', 'C1', 'C2', 'C4', 'C5', 'C9', 'C11', 'C12', 'G2', 'G9', 'G11', 'G12', 'G13', 'G14', 'M0', 'M1', 'M2', 'M3', 'M4', 'M5', 'M6'];
const MODULES_CONTEXTE = new Set(['S0', 'M0', 'M1', 'M2']);
const MODULE_INVENTAIRES = {
code: 'M5',
@ -47,7 +47,7 @@ const CONTEXTES = {
description: 'Modules de gestion et administration.',
sections: [
{ titre: 'Finances', codes: ['S1T', 'S5', 'M4'] },
{ titre: 'Membres et structure', codes: ['G2', 'G11', 'G12', 'G9', 'G13'] },
{ titre: 'Membres et structure', codes: ['G14', 'G2', 'G11', 'G12', 'G9', 'G13'] },
{ titre: 'Inventaire', codes: ['M5'] },
],
},

View file

@ -0,0 +1,146 @@
import React, { useEffect, useState } from 'react';
import PageHeader from '../components/PageHeader';
import { api } from '../stores/api';
import { useStore } from '../stores/store';
const jourLabel = (jour) => {
if (!jour) return 'Jour à préciser';
return jour.charAt(0).toUpperCase() + jour.slice(1);
};
export default function GestionGroupes() {
const { loadGroupes, logout } = useStore();
const [groupes, setGroupes] = useState([]);
const [ouvertId, setOuvertId] = useState('');
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [msg, setMsg] = useState('');
const [error, setError] = useState('');
const charger = async () => {
setLoading(true);
setError('');
try {
const data = await api.get('/admin/groupes');
setGroupes(data);
if (!ouvertId && data.length) setOuvertId(data[0].id);
} catch (e) {
setError(e.message);
} finally {
setLoading(false);
}
};
useEffect(() => { charger(); }, []);
const installerDemo = async () => {
const confirmation = window.prompt('Cette action ajoute le groupe de démonstration "Groupe Résilience" sans effacer les groupes existants. Tapez CONFIRMER pour continuer.');
if (confirmation !== 'CONFIRMER') return;
setBusy(true);
setError('');
setMsg('');
try {
const res = await api.post('/admin/demo');
setMsg(res.message || 'Groupe de démonstration installé');
await charger();
await loadGroupes();
} catch (e) {
setError(e.message);
} finally {
setBusy(false);
}
};
const remettreAZero = async () => {
const confirmation = window.prompt('Cette action efface toute la base et recrée seulement le compte Sysadmin. Tapez EFFACER pour continuer.');
if (confirmation !== 'EFFACER') return;
setBusy(true);
setError('');
try {
await api.post('/admin/reset');
alert('Base remise à zéro. Reconnectez-vous avec Sysadmin / 0000.');
logout();
} catch (e) {
setError(e.message);
setBusy(false);
}
};
return (
<div className="min-h-screen bg-gray-100">
<PageHeader titre="Gestion des groupes" sousTitre="Groupes, démo et opérations sysadmin" />
<div className="p-4 space-y-4">
{msg && <div className="bg-green-50 text-green-700 p-3 rounded-xl text-center text-sm font-medium">{msg}</div>}
{error && <div className="bg-red-50 text-red-600 p-3 rounded-xl text-center text-sm">{error}</div>}
<div className="grid grid-cols-1 gap-2">
<button
onClick={installerDemo}
disabled={busy}
className="w-full min-h-[48px] p-3 bg-aa-blue text-white rounded-xl text-sm font-bold disabled:opacity-40"
>
Installer le groupe de démonstration
</button>
<button
onClick={remettreAZero}
disabled={busy}
className="w-full min-h-[48px] p-3 bg-red-50 border border-red-200 text-red-700 rounded-xl text-sm font-bold disabled:opacity-40"
>
Remettre toute la BD à vide
</button>
</div>
{loading && <p className="text-center text-gray-400 py-8">Chargement...</p>}
<div className="space-y-3">
{groupes.map((groupe) => {
const ouvert = ouvertId === groupe.id;
const horaire = `${jourLabel(groupe.jour_reunion)}${groupe.heure_reunion ? ` · ${groupe.heure_reunion}` : ''}`;
return (
<section key={groupe.id} className="overflow-hidden rounded-2xl bg-white shadow-sm">
<button
type="button"
onClick={() => setOuvertId(ouvert ? '' : groupe.id)}
className="flex w-full items-center justify-between gap-3 p-4 text-left active:bg-gray-50"
aria-expanded={ouvert}
>
<span className="min-w-0">
<span className="block truncate text-lg font-bold text-gray-900">{groupe.nom}</span>
<span className="mt-1 block text-sm font-semibold text-aa-blue">{horaire}</span>
</span>
<span className={`shrink-0 text-2xl text-aa-blue transition ${ouvert ? 'rotate-180' : ''}`}>⌄</span>
</button>
{ouvert && (
<div className="border-t border-gray-100 p-4 pt-3 space-y-3">
<div className="grid grid-cols-3 gap-2 text-center">
<Compteur label="Membres" valeur={groupe.membres} />
<Compteur label="Rencontres" valeur={groupe.reunions} />
<Compteur label="Traces" valeur={groupe.traces} />
</div>
<div className="space-y-2 rounded-xl bg-gray-50 p-3 text-sm text-gray-700">
{groupe.adresse_local && <p><span className="font-semibold text-gray-900">Lieu : </span>{groupe.adresse_local}</p>}
{groupe.format_reunion && <p><span className="font-semibold text-gray-900">Format : </span>{groupe.format_reunion}</p>}
<p><span className="font-semibold text-gray-900">District : </span>{groupe.district || '—'}</p>
<p><span className="font-semibold text-gray-900">Région : </span>{groupe.region || '—'}</p>
</div>
</div>
)}
</section>
);
})}
</div>
</div>
</div>
);
}
function Compteur({ label, valeur }) {
return (
<div className="rounded-xl bg-gray-50 p-3">
<p className="text-lg font-extrabold text-gray-900">{valeur ?? 0}</p>
<p className="text-xs text-gray-400">{label}</p>
</div>
);
}

View file

@ -51,9 +51,8 @@ export default function Login() {
<div className="min-h-screen bg-gradient-to-b from-aa-blue to-blue-900 p-4">
<div className="mx-auto flex min-h-screen w-full max-w-md flex-col py-6">
<div className="text-center mb-5">
<div className="text-5xl mb-3">☮️</div>
<h1 className="text-2xl font-bold text-white">Groupe Méditation</h1>
<p className="text-blue-200 text-sm mt-1">Choisir un groupe</p>
<h1 className="text-2xl font-bold text-white">Choisir un groupe</h1>
<p className="text-blue-200 text-sm mt-1">District 87-16 · Région 87</p>
</div>
<a

View file

@ -13,7 +13,6 @@ export default function Profil() {
const {
setPage,
estSysadmin,
logout,
postes,
modulesConsultation,
modulesSaisie,
@ -31,7 +30,6 @@ export default function Profil() {
const [notificationTypes, setNotificationTypes] = useState([]);
const [notificationPrefs, setNotificationPrefs] = useState({});
const [savingPrefs, setSavingPrefs] = useState(false);
const [adminBusy, setAdminBusy] = useState(false);
useEffect(() => {
api.get('/membres/moi').then((data) => {
@ -149,27 +147,6 @@ export default function Profil() {
}
};
const operationAdminBd = async (mode) => {
const message = mode === 'demo'
? 'Cette action va créer le groupe de démonstration "Groupe Résilience" sans effacer les groupes existants. Tapez CONFIRMER pour continuer.'
: 'Cette action va remettre la base à zéro et effacer les données actuelles. Tapez CONFIRMER pour continuer.';
const confirmation = window.prompt(message);
if (confirmation !== 'CONFIRMER') return;
setAdminBusy(true);
setError('');
try {
await api.post(mode === 'demo' ? '/admin/demo' : '/admin/reset');
alert(mode === 'demo'
? 'Groupe de démonstration installé. Vous pouvez le choisir à la page de connexion.'
: 'Base remise à zéro. Reconnectez-vous avec Sysadmin / 0000.');
if (mode === 'reset') logout();
} catch (e) {
setError(e.message);
} finally {
setAdminBusy(false);
}
};
if (!profil) return <div className="flex items-center justify-center min-h-screen"><p className="text-gray-500">Chargement...</p></div>;
const Field = ({ label, value }) => (
@ -362,31 +339,6 @@ export default function Profil() {
)}
</div>
{estSysadmin && (
<div className="bg-white rounded-2xl p-4 shadow-sm space-y-3 border border-red-100">
<div>
<h2 className="font-bold text-red-700">Administration sysadmin</h2>
<p className="text-sm text-gray-500 mt-1">
La remise à zéro efface la base courante. Le groupe de démonstration est ajouté séparément, sans remplacer les données existantes.
</p>
</div>
<button
onClick={() => operationAdminBd('reset')}
disabled={adminBusy}
className="w-full min-h-[44px] p-3 bg-red-50 border border-red-200 text-red-700 rounded-xl font-bold disabled:opacity-40"
>
Remettre la BD à vide
</button>
<button
onClick={() => operationAdminBd('demo')}
disabled={adminBusy}
className="w-full min-h-[44px] p-3 bg-aa-blue text-white rounded-xl font-bold disabled:opacity-40"
>
Installer le groupe de démonstration
</button>
</div>
)}
{/* Changer PIN */}
<div className="bg-white rounded-2xl p-4 shadow-sm">
{!showPin ? (