312 lines
15 KiB
JavaScript
312 lines
15 KiB
JavaScript
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>;
|
||
}
|