groupe-meditation/frontend/src/pages/GererPostes.jsx
Daniel Allaire 69309acd13 Intégrer rencontres et assemblées pilotées par tuiles
Refond les pages Rencontres et Assemblées autour de tuiles ouvrables avec actions locales, PDF serveur, ventes, collectes, présences, décisions, postes et contributions.

Ajoute les invitations publiques d'assemblée en lecture seule avec lien expirant, l'adoption formelle du rapport du trésorier avec proposeur/secondeur, et bloque les contributions tant que ce rapport n'est pas adopté.

Uniformise la navigation et les entêtes, retire les modules redondants des contextes, ajoute la création de comptes membres par exécutif et les données démo reliées aux nouveaux processus.

Ce commit capture un lot fonctionnel important; une refactorisation ciblée du module Assemblées, puis Trésorerie/Gouvernance, est maintenant pertinente pour isoler les règles métier et faciliter les tests.
2026-05-31 09:02:02 -04:00

350 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useState, useEffect, useMemo } from 'react';
import { api } from '../stores/api';
import PageHeader from '../components/PageHeader';
const CATEGORIES = [
{ value: 'executif', label: 'Exécutif' },
{ value: 'service', label: 'Service' },
{ value: 'physique', label: 'Physique' },
];
const MODULES = [
['S0', 'Rencontres'], ['M0', "Assemblée d'affaires"],
['C5', 'Gouvernance'],
['S1T', 'Trésorerie'], ['M4', 'Donner au suivant'],
['M1', 'Rédiger PV'], ['M2', 'Présences'], ['M3', 'Rapport RSG'],
['S2', 'Remise jeton'], ['M6', 'Commander jetons'],
['S3', 'Vente littérature'], ['M5', 'Ajuster inventaire'],
['G2', 'Gestion membres'], ['G9', 'Paramètres'],
['G11', 'Gestion mandats'], ['G12', 'Gestion postes'],
];
const GROUPES_MODULES = [
{ titre: 'Réunion', codes: ['S0'] },
{ titre: 'Affaires', codes: ['M0', 'M1', 'M2', 'M3', 'C5'] },
{ titre: 'Finances', codes: ['S1T', 'S5', 'M4'] },
{ titre: 'Inventaire', codes: ['M5', 'M6'] },
{ titre: 'Administration', codes: ['G2', 'G9', 'G11', 'G12', 'G13'] },
];
export default function GererPostes() {
const [postes, setPostes] = useState([]);
const [modulesPostes, setModulesPostes] = useState({});
const [candidatures, setCandidatures] = useState([]);
const [tab, setTab] = useState('postes');
const [posteOuvert, setPosteOuvert] = useState(null);
const [posteDetail, setPosteDetail] = useState(null);
const [posteAttentes, setPosteAttentes] = useState(null);
const [posteEdition, setPosteEdition] = useState(null);
const [showForm, setShowForm] = useState(false);
const [msg, setMsg] = useState('');
const [error, setError] = useState('');
const [nom, setNom] = useState('');
const [categorie, setCategorie] = useState('service');
const [description, setDescription] = useState('');
const [prerequis, setPrerequis] = useState('');
const [informations, setInformations] = useState('');
const charger = async () => {
try {
const [p, mp, c] = await Promise.all([
api.get('/postes/?inclure_abolis=true'),
api.get('/postes/modules'),
api.get('/postes/candidatures'),
]);
setPostes(p);
setModulesPostes(mp);
setCandidatures(c);
} catch (e) { setError(e.message); }
};
useEffect(() => { charger(); }, []);
const flash = (m) => { setMsg(m); setTimeout(() => setMsg(''), 2500); };
const creerPoste = async () => {
if (!nom.trim()) { setError('Nom requis'); return; }
setError('');
try {
await api.post('/postes/', {
nom: nom.trim(), categorie,
description: description.trim() || null,
prerequis: prerequis.trim() || null,
informations: informations.trim() || null,
});
setNom(''); setDescription(''); setPrerequis(''); setInformations(''); setShowForm(false);
flash('Poste créé ✓');
await charger();
} catch (e) { setError(e.message); }
};
const abolir = async (id, nomPoste) => {
if (!confirm(`Abolir le poste «${nomPoste}» ?\nTous les mandats actifs seront terminés.`)) return;
try {
const res = await api.request('DELETE', `/postes/${id}`);
flash(`Poste aboli — ${res.mandats_termines} mandat(s) terminé(s)`);
await charger();
} catch (e) { setError(e.message); }
};
const reactiver = async (id) => {
try {
await api.post(`/postes/${id}/reactiver`);
flash('Poste réactivé ✓');
await charger();
} catch (e) { setError(e.message); }
};
const supprimerPermanent = async (id, nomPoste) => {
if (!confirm(`Supprimer définitivement le poste «${nomPoste}» ?`)) return;
try {
await api.request('DELETE', `/postes/${id}/permanent`);
flash('Poste supprimé');
await charger();
} catch (e) { setError(e.message); }
};
const ouvrirEdition = (p) => {
setPosteEdition(p.id);
setNom(p.nom || '');
setCategorie(p.categorie || 'service');
setDescription(p.description || '');
setPrerequis(p.prerequis || '');
setInformations(p.informations || '');
};
const modifierPoste = async (id) => {
if (!nom.trim()) { setError('Nom requis'); return; }
setError('');
try {
await api.patch(`/postes/${id}`, {
nom: nom.trim(),
categorie,
description: description.trim() || null,
prerequis: prerequis.trim() || null,
informations: informations.trim() || null,
});
setPosteEdition(null); setNom(''); setDescription(''); setPrerequis(''); setInformations('');
flash('Poste modifié ✓');
await charger();
} catch (e) { setError(e.message); }
};
const toggleModule = async (posteId, code) => {
const actuel = new Set(modulesPostes[posteId] || []);
if (actuel.has(code)) actuel.delete(code);
else actuel.add(code);
const codes = [...actuel];
setModulesPostes({ ...modulesPostes, [posteId]: codes });
try {
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) => {
try {
await api.patch(`/postes/candidatures/${id}?statut=${statut}`);
flash(statut === 'acceptee' ? 'Candidature acceptée ✓' : 'Candidature refusée');
await charger();
} catch (e) { setError(e.message); }
};
const CAT_ICON = { executif: '🔵', service: '🟣', physique: '🟢' };
const moduleParCode = useMemo(() => new Map(MODULES.map(([code, label]) => [code, label])), []);
const groupesPourPoste = (posteId) => GROUPES_MODULES.map((groupe) => ({
...groupe,
modules: groupe.codes
.filter((code) => moduleParCode.has(code))
.map((code) => [code, moduleParCode.get(code), (modulesPostes[posteId] || []).includes(code)]),
})).filter((groupe) => groupe.modules.length > 0);
return (
<div className="min-h-screen bg-gray-100">
<PageHeader titre="Gestion des postes" sousTitre={`${postes.length} postes · ${candidatures.length} candidatures`} />
<div className="p-3 space-y-3">
{msg && <div className="bg-green-50 text-green-700 p-3 rounded-xl text-center text-sm font-medium">{msg}</div>}
{error && <div className="bg-red-50 text-red-600 p-3 rounded-xl text-center text-sm">{error}</div>}
<div className="grid grid-cols-2 gap-2">
{[
['postes', `Postes (${postes.length})`],
['candidatures', `Candidatures (${candidatures.length})`],
].map(([t, label]) => (
<button key={t} onClick={() => { setTab(t); setError(''); }}
className={`min-h-[44px] rounded-xl text-xs font-bold transition whitespace-nowrap px-2 ${
tab === t ? 'bg-aa-blue text-white' : 'bg-white text-gray-500'
}`}>{label}</button>
))}
</div>
{tab === 'postes' && (
<div className="space-y-3">
<button onClick={() => setShowForm(!showForm)}
className="w-full min-h-[52px] py-3 bg-green-50 text-green-700 rounded-xl font-bold border border-green-200">
+ Créer un poste
</button>
{showForm && (
<div className="bg-white rounded-2xl p-4 shadow-sm space-y-3">
<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 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-xl p-4 shadow-sm border ${!p.actif ? 'border-dashed border-gray-300' : posteDetail === p.id ? 'border-aa-blue/30' : 'border-transparent'}`}>
<button type="button" onClick={() => setPosteDetail(posteDetail === p.id ? null : p.id)}
className="w-full text-left">
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span>{CAT_ICON[p.categorie] || '⚪'}</span>
<p className="font-bold text-gray-800 truncate">{p.nom}</p>
{!p.actif && <span className="text-[11px] bg-gray-100 text-gray-500 px-2 py-0.5 rounded-full">Aboli</span>}
</div>
{p.description && <p className="text-xs text-gray-500 mt-1 line-clamp-2">{p.description}</p>}
</div>
<span className="text-xl text-gray-300">{posteDetail === p.id ? '⌃' : '›'}</span>
</div>
</button>
{posteDetail === p.id && (
<div className="mt-3 pt-3 border-t border-gray-100 space-y-3 text-sm text-gray-600">
{p.prerequis && <p><span className="font-bold">Prérequis :</span> {p.prerequis}</p>}
{p.informations && <p className="whitespace-pre-wrap">{p.informations}</p>}
{!p.prerequis && !p.informations && <p>Aucun attribut additionnel.</p>}
</div>
)}
<div className="mt-3 pt-3 border-t border-gray-100">
<div className="grid grid-cols-2 gap-2">
<button type="button" onClick={() => setPosteOuvert(posteOuvert === p.id ? null : p.id)}
className={`min-h-[48px] rounded-xl text-sm font-bold border ${
posteOuvert === p.id ? 'bg-aa-blue text-white border-aa-blue' : 'bg-gray-50 text-gray-700 border-gray-200'
}`}>
Permissions
</button>
<button type="button" onClick={() => setPosteAttentes(posteAttentes === p.id ? null : p.id)}
className="min-h-[48px] rounded-xl text-sm font-bold border bg-gray-50 text-gray-700 border-gray-200">
Les attentes
</button>
<button type="button" onClick={() => ouvrirEdition(p)}
className="min-h-[48px] rounded-xl text-sm font-bold border bg-blue-50 text-blue-700 border-blue-100">
Modifier
</button>
{p.actif ? (
<button type="button" onClick={() => abolir(p.id, p.nom)}
className="min-h-[48px] rounded-xl text-sm font-bold border bg-red-50 text-red-700 border-red-100">
Abolir
</button>
) : (
<button type="button" onClick={() => reactiver(p.id)}
className="min-h-[48px] rounded-xl text-sm font-bold border bg-green-50 text-green-700 border-green-100">
Réactiver
</button>
)}
</div>
{posteAttentes === p.id && (
<div className="mt-3 rounded-xl bg-gray-50 p-3 text-sm text-gray-600 whitespace-pre-wrap">
{p.informations || p.prerequis || 'Aucune attente renseignée.'}
</div>
)}
{posteEdition === p.id && (
<div className="mt-3 bg-gray-50 rounded-2xl p-3 space-y-3">
<input value={nom} onChange={(e) => setNom(e.target.value)} placeholder="Nom du poste"
className="w-full p-3 bg-white rounded-xl border-0 focus:ring-2 focus:ring-aa-blue" />
<select value={categorie} onChange={(e) => setCategorie(e.target.value)}
className="w-full p-3 bg-white rounded-xl border-0 focus:ring-2 focus:ring-aa-blue">
{CATEGORIES.map((c) => <option key={c.value} value={c.value}>{c.label}</option>)}
</select>
<input value={description} onChange={(e) => setDescription(e.target.value)} placeholder="Description"
className="w-full p-3 bg-white rounded-xl border-0 focus:ring-2 focus:ring-aa-blue" />
<input value={prerequis} onChange={(e) => setPrerequis(e.target.value)} placeholder="Prérequis"
className="w-full p-3 bg-white rounded-xl border-0 focus:ring-2 focus:ring-aa-blue" />
<textarea value={informations} onChange={(e) => setInformations(e.target.value)} placeholder="Attentes"
rows={3} className="w-full p-3 bg-white rounded-xl border-0 focus:ring-2 focus:ring-aa-blue resize-none" />
<div className="flex gap-2">
<button onClick={() => modifierPoste(p.id)}
className="flex-1 py-3 bg-aa-blue text-white rounded-xl text-sm font-bold">Enregistrer</button>
<button onClick={() => setPosteEdition(null)}
className="flex-1 py-3 bg-gray-200 text-gray-700 rounded-xl text-sm font-bold">Annuler</button>
</div>
</div>
)}
{posteOuvert === p.id && (
<div className="mt-3 space-y-4">
{groupesPourPoste(p.id).map((groupe) => (
<div key={groupe.titre} className="space-y-2">
<p className="text-xs font-bold text-gray-400 uppercase tracking-wider">{groupe.titre}</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{groupe.modules.map(([code, label, actif]) => (
<button
key={code}
type="button"
onClick={() => toggleModule(p.id, code)}
disabled={!p.actif}
className={`min-h-[44px] 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'
} disabled:opacity-40`}
>
{code} · {label}
</button>
))}
</div>
</div>
))}
</div>
)}
{!p.actif && (
<button onClick={() => supprimerPermanent(p.id, p.nom)}
className="w-full mt-2 py-2 bg-red-50 text-red-700 rounded-xl text-sm font-bold border border-red-100">
Supprimer définitivement
</button>
)}
</div>
</div>
))}
</div>
)}
{tab === 'candidatures' && (
candidatures.length === 0 ? (
<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>}
<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">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">Refuser</button>
</div>
</div>
))}
</div>
)
)}
</div>
</div>
);
}