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="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"), 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 _modules in [MODULES_SAISIE_TOUS, MODULES_GESTION, *MODULES_SAISIE.values()]:
for _m in _modules: for _m in _modules:
@ -104,7 +105,7 @@ async def accueil(
est_executif=True, est_executif=True,
modules_consultation=MODULES_CONSULTATION, modules_consultation=MODULES_CONSULTATION,
modules_saisie=modules_saisie_sysadmin, modules_saisie=modules_saisie_sysadmin,
modules_gestion=MODULES_GESTION, modules_gestion=[MODULE_SYSADMIN, *MODULES_GESTION],
groupe=groupe_info, groupe=groupe_info,
) )

View file

@ -1,10 +1,15 @@
"""Routes d'administration reservees au sysadmin.""" """Routes d'administration reservees au sysadmin."""
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import func, select
from app.core.database import get_db from app.core.database import get_db
from app.core.security import get_current_membre, is_sysadmin from app.core.security import get_current_membre, is_sysadmin
from app.services.admin_database import initialiser_demo, remettre_a_zero 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"]) router = APIRouter(prefix="/admin", tags=["Administration"])
@ -29,3 +34,38 @@ async def demo_database(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
return await initialiser_demo(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 AssembleeAffaires from './pages/AssembleeAffaires';
import AssembleeInvite from './pages/AssembleeInvite'; import AssembleeInvite from './pages/AssembleeInvite';
import JournalActions from './pages/JournalActions'; import JournalActions from './pages/JournalActions';
import GestionGroupes from './pages/GestionGroupes';
import PrincipesUtilisation from './pages/PrincipesUtilisation'; import PrincipesUtilisation from './pages/PrincipesUtilisation';
import ModePapier from './pages/ModePapier'; import ModePapier from './pages/ModePapier';
import BottomNav from './components/BottomNav'; import BottomNav from './components/BottomNav';
@ -83,6 +84,7 @@ export default function App() {
case 'G11': pageActive = <GestionMandats />; break; case 'G11': pageActive = <GestionMandats />; break;
case 'G12': pageActive = <GererPostes />; break; case 'G12': pageActive = <GererPostes />; break;
case 'G13': pageActive = <JournalActions />; break; case 'G13': pageActive = <JournalActions />; break;
case 'G14': pageActive = <GestionGroupes />; break;
case 'M1': pageActive = <RedigerPV />; break; case 'M1': pageActive = <RedigerPV />; break;
case 'M2': pageActive = <Presences />; break; case 'M2': pageActive = <Presences />; break;
case 'M3': pageActive = <RapportRSG />; break; case 'M3': pageActive = <RapportRSG />; break;

View file

@ -12,7 +12,7 @@ const COULEURS = {
gray: 'bg-gray-50 border-gray-200 text-gray-700', 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 MODULES_CONTEXTE = new Set(['S0', 'M0', 'M1', 'M2']);
const MODULE_INVENTAIRES = { const MODULE_INVENTAIRES = {
code: 'M5', code: 'M5',
@ -47,7 +47,7 @@ const CONTEXTES = {
description: 'Modules de gestion et administration.', description: 'Modules de gestion et administration.',
sections: [ sections: [
{ titre: 'Finances', codes: ['S1T', 'S5', 'M4'] }, { 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'] }, { 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="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="mx-auto flex min-h-screen w-full max-w-md flex-col py-6">
<div className="text-center mb-5"> <div className="text-center mb-5">
<div className="text-5xl mb-3">☮️</div> <h1 className="text-2xl font-bold text-white">Choisir un groupe</h1>
<h1 className="text-2xl font-bold text-white">Groupe Méditation</h1> <p className="text-blue-200 text-sm mt-1">District 87-16 · Région 87</p>
<p className="text-blue-200 text-sm mt-1">Choisir un groupe</p>
</div> </div>
<a <a

View file

@ -13,7 +13,6 @@ export default function Profil() {
const { const {
setPage, setPage,
estSysadmin, estSysadmin,
logout,
postes, postes,
modulesConsultation, modulesConsultation,
modulesSaisie, modulesSaisie,
@ -31,7 +30,6 @@ export default function Profil() {
const [notificationTypes, setNotificationTypes] = useState([]); const [notificationTypes, setNotificationTypes] = useState([]);
const [notificationPrefs, setNotificationPrefs] = useState({}); const [notificationPrefs, setNotificationPrefs] = useState({});
const [savingPrefs, setSavingPrefs] = useState(false); const [savingPrefs, setSavingPrefs] = useState(false);
const [adminBusy, setAdminBusy] = useState(false);
useEffect(() => { useEffect(() => {
api.get('/membres/moi').then((data) => { 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>; 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 }) => ( const Field = ({ label, value }) => (
@ -362,31 +339,6 @@ export default function Profil() {
)} )}
</div> </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 */} {/* Changer PIN */}
<div className="bg-white rounded-2xl p-4 shadow-sm"> <div className="bg-white rounded-2xl p-4 shadow-sm">
{!showPin ? ( {!showPin ? (