groupe-meditation/frontend/src/pages/GererMembres.jsx

312 lines
15 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 } from 'react';
import { api } from '../stores/api';
import { useStore } from '../stores/store';
import PageHeader from '../components/PageHeader';
export default function GererMembres() {
const { membre, groupe, estSysadmin, impersoner } = useStore();
const [membres, setMembres] = useState([]);
const [loading, setLoading] = useState(true);
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 [showCreate, setShowCreate] = useState(false);
const [createLoading, setCreateLoading] = useState(false);
const [createForm, setCreateForm] = useState({
prenom: '',
nom: '',
telephone: '',
courriel: '',
pin: '',
date_abstinence: '',
});
const charger = async () => {
setLoading(true);
try {
const data = await api.get('/membres/');
setMembres(data);
} catch {}
setLoading(false);
};
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 ${groupe?.nom || 'le groupe'} 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`);
setMsg(res.message);
setConfirmAction(null);
setDetail(null);
charger();
setTimeout(() => setMsg(''), 3000);
} catch (e) { alert(e.message); }
};
const reactiver = async (id) => {
try {
const res = await api.patch(`/membres/${id}/reactiver`);
setMsg(res.message);
setConfirmAction(null);
setDetail(null);
charger();
setTimeout(() => setMsg(''), 3000);
} catch (e) { alert(e.message); }
};
const droitOubli = async (id) => {
try {
const res = await api.request('DELETE', `/membres/${id}/oubli`);
setMsg(res.message);
setConfirmAction(null);
setDetail(null);
charger();
setTimeout(() => setMsg(''), 3000);
} catch (e) { alert(e.message); }
};
const agirComme = async (id) => {
try {
await impersoner(id);
} catch (e) { alert(e.message); }
};
const creerCompte = async () => {
setCreateLoading(true);
try {
const payload = {
...createForm,
nom: createForm.nom.trim() || null,
date_abstinence: createForm.date_abstinence || null,
};
const res = await api.post('/membres/', payload);
setMsg(`Compte créé pour ${res.prenom}`);
setShowCreate(false);
setCreateForm({ prenom: '', nom: '', telephone: '', courriel: '', pin: '', date_abstinence: '' });
await charger();
setTimeout(() => setMsg(''), 3000);
} catch (e) {
alert(e.message);
}
setCreateLoading(false);
};
const estAnonymise = (m) => Boolean(m?.anonymise || m?.courriel?.startsWith('supprime_'));
const telephoneVisible = (m) => m?.telephone && m.telephone !== '000-000-0000';
const courrielVisible = (m) => m?.courriel && !m.courriel.startsWith('supprime_');
// Vue détail d'un membre
if (detail) {
const anonymise = estAnonymise(detail);
const membreCourant = detail.id === membre?.id;
const actionsAdministrativesPermises = !detail.is_sysadmin && !membreCourant;
return (
<div className="min-h-screen bg-gray-100">
<div className="bg-aa-blue text-white p-4 flex items-center gap-3">
<button onClick={() => { setDetail(null); setConfirmAction(null); }} className="text-2xl">←</button>
<h1 className="text-lg font-bold">{detail.prenom} {detail.nom}</h1>
</div>
<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>}
<div className="bg-white rounded-2xl p-4 shadow-sm space-y-3">
<div><p className="text-xs text-gray-400">Prénom</p><p className="font-medium">{detail.prenom}</p></div>
<div><p className="text-xs text-gray-400">Nom</p><p className="font-medium">{detail.nom || '—'}</p></div>
<div><p className="text-xs text-gray-400">Téléphone</p><p className="font-medium">{telephoneVisible(detail) ? detail.telephone : '—'}</p></div>
<div><p className="text-xs text-gray-400">Courriel</p><p className="font-medium">{courrielVisible(detail) ? detail.courriel : '—'}</p></div>
<div><p className="text-xs text-gray-400">Date d'abstinence</p><p className="font-medium">{detail.date_abstinence || '—'}</p></div>
<div><p className="text-xs text-gray-400">Statut</p>
<span className={`inline-block px-2 py-1 rounded-full text-xs font-bold ${detail.actif ? 'bg-green-100 text-green-700' : anonymise ? 'bg-gray-100 text-gray-600' : 'bg-red-100 text-red-700'}`}>
{anonymise ? 'Anonymisé' : detail.actif ? 'Actif' : 'Inactif'}
</span>
</div>
</div>
{/* Contact rapide */}
<div className="grid grid-cols-2 gap-2">
{telephoneVisible(detail) && (
<a href={`tel:${detail.telephone}`}
className="p-3 bg-green-50 border border-green-200 rounded-xl text-center text-green-700 font-bold text-sm">
📞 Appeler
</a>
)}
{courrielVisible(detail) && (
<a href={`mailto:${detail.courriel}`}
className="p-3 bg-blue-50 border border-blue-200 rounded-xl text-center text-blue-700 font-bold text-sm">
✉️ Courriel
</a>
)}
</div>
{/* Actions */}
{estSysadmin && detail.id !== membre?.id && !anonymise && !confirmAction && (
<button onClick={() => agirComme(detail.id)}
className="w-full p-3 bg-aa-blue text-white rounded-xl font-bold text-sm">
Agir comme ce membre
</button>
)}
{actionsAdministrativesPermises && detail.actif && !confirmAction && (
<button onClick={() => setConfirmAction('desactiver')}
className="w-full p-3 bg-orange-50 border border-orange-200 text-orange-700 rounded-xl font-bold text-sm">
Désactiver ce membre
</button>
)}
{actionsAdministrativesPermises && !detail.actif && !anonymise && !confirmAction && (
<button onClick={() => setConfirmAction('reactiver')}
className="w-full p-3 bg-green-50 border border-green-200 text-green-700 rounded-xl font-bold text-sm">
Réactiver ce membre
</button>
)}
{actionsAdministrativesPermises && !anonymise && !confirmAction && (
<button onClick={() => setConfirmAction('oubli')}
className="w-full p-3 bg-red-50 border border-red-200 text-red-700 rounded-xl font-bold text-sm">
Droit à l'oubli (irréversible)
</button>
)}
{/* Confirmations */}
{confirmAction === 'desactiver' && (
<div className="bg-orange-50 rounded-2xl p-4 border border-orange-200">
<p className="text-sm text-orange-800 mb-3">Désactiver {detail.prenom} ? Le membre ne pourra plus se connecter.</p>
<div className="flex gap-2">
<button onClick={() => desactiver(detail.id)} className="flex-1 p-3 bg-orange-500 text-white rounded-xl font-bold">Confirmer</button>
<button onClick={() => setConfirmAction(null)} className="flex-1 p-3 bg-gray-200 text-gray-700 rounded-xl font-bold">Annuler</button>
</div>
</div>
)}
{confirmAction === 'reactiver' && (
<div className="bg-green-50 rounded-2xl p-4 border border-green-200">
<p className="text-sm text-green-800 mb-3">Réactiver {detail.prenom} ? Le membre pourra de nouveau se connecter avec son identifiant et son PIN existants.</p>
<div className="flex gap-2">
<button onClick={() => reactiver(detail.id)} className="flex-1 p-3 bg-green-600 text-white rounded-xl font-bold">Confirmer</button>
<button onClick={() => setConfirmAction(null)} className="flex-1 p-3 bg-gray-200 text-gray-700 rounded-xl font-bold">Annuler</button>
</div>
</div>
)}
{confirmAction === 'oubli' && (
<div className="bg-red-50 rounded-2xl p-4 border border-red-200">
<p className="text-sm text-red-800 mb-3">⚠️ Cette action est irréversible. Le prénom de {detail.prenom} demeurera pour préserver l'historique; le nom, le téléphone, l'identifiant, le PIN et la date d'abstinence seront effacés ou neutralisés.</p>
<div className="flex gap-2">
<button onClick={() => droitOubli(detail.id)} className="flex-1 p-3 bg-red-600 text-white rounded-xl font-bold">Anonymiser</button>
<button onClick={() => setConfirmAction(null)} className="flex-1 p-3 bg-gray-200 text-gray-700 rounded-xl font-bold">Annuler</button>
</div>
</div>
)}
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gray-100">
<PageHeader titre="Gestion des membres" sousTitre="Membres, invitations, accès" />
<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>
<div className="bg-white rounded-2xl p-4 shadow-sm space-y-3">
<button onClick={() => setShowCreate(!showCreate)}
className="w-full flex items-center justify-between font-bold text-gray-800">
<span>Créer un compte membre</span>
<span className="text-gray-400">{showCreate ? '−' : '+'}</span>
</button>
{showCreate && (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-2">
<Champ label="Prénom"><input value={createForm.prenom} onChange={(e) => setCreateForm({ ...createForm, prenom: e.target.value })} className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue" /></Champ>
<Champ label="Nom"><input value={createForm.nom} onChange={(e) => setCreateForm({ ...createForm, nom: e.target.value })} className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue" /></Champ>
</div>
<Champ label="Téléphone"><input value={createForm.telephone} onChange={(e) => setCreateForm({ ...createForm, telephone: e.target.value })} className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue" /></Champ>
<Champ label="Courriel"><input type="email" value={createForm.courriel} onChange={(e) => setCreateForm({ ...createForm, courriel: e.target.value })} className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue" /></Champ>
<div className="grid grid-cols-2 gap-2">
<Champ label="PIN initial"><input inputMode="numeric" maxLength={4} value={createForm.pin} onChange={(e) => setCreateForm({ ...createForm, pin: e.target.value.replace(/\D/g, '').slice(0, 4) })} className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue" /></Champ>
<Champ label="Date d'abstinence"><input type="date" value={createForm.date_abstinence} onChange={(e) => setCreateForm({ ...createForm, date_abstinence: e.target.value })} className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue" /></Champ>
</div>
<button onClick={creerCompte} disabled={createLoading || !createForm.prenom || !createForm.telephone || !createForm.courriel || createForm.pin.length !== 4}
className="w-full p-3 bg-aa-blue text-white rounded-xl font-bold disabled:opacity-40">
{createLoading ? '...' : 'Créer le compte'}
</button>
</div>
)}
</div>
{loading ? (
<p className="text-center text-gray-400 py-8">Chargement...</p>
) : (
membres.map((m) => (
<button key={m.id} onClick={() => setDetail(m)}
className="w-full bg-white rounded-2xl p-4 shadow-sm flex items-center justify-between active:scale-[0.98] transition">
<div>
<p className="font-bold text-gray-800">{m.prenom} {m.nom}</p>
<p className="text-xs text-gray-400">
{estAnonymise(m) ? 'Anonymisé' : m.actif ? (m.telephone || m.courriel || '—') : 'Inactif'}
</p>
</div>
<span className="text-gray-300">→</span>
</button>
))
)}
</div>
</div>
);
}
function Champ({ label, children }) {
return <label className="block"><span className="text-xs text-gray-400 block mb-1">{label}</span>{children}</label>;
}