Saison {season} · {members.length - actifs} à renouveler
ⓘ
Un membre non validé pour la saison en cours n'apparaît pas dans l'annuaire public et ne peut pas
être inscrit aux rencontres. La saison court du 1er septembre au 31 août.
{accessError &&
{accessError}
}
Entreprise
Secteur
Dirigeant
Saison
Accès
{members.map((m) => (
{m.nom}
{m.secteur}
{m.dirigeant}
{m.valide ? `Validé ${season}` : 'Non validé'}
generateAccess(m)}
/>
{isAdmin && (
)}
))}
{modal && (
setModal(null)}
onSaved={(result) => {
setModal(null);
reload();
if (result?.tempPassword) {
setCredentials({ recipient: result.email, tempPassword: result.tempPassword });
} else if (result?.accessError) {
setAccessError(`Membre créé, mais accès non créé : ${result.accessError}`);
}
}}
/>
)}
{credentials && (
setCredentials(null)}
/>
)}
);
}
/* ---------------- Rencontres ---------------- */
function RencFormModal({ renc, onClose, onSaved }) {
const { user } = useAuth();
const [form, setForm] = useState(
renc
? { titre: renc.titre, date_renc: String(renc.date_renc).slice(0, 10), heure: renc.heure, lieu: renc.lieu, description: renc.description, places: renc.places, participants_par_compte: renc.participants_par_compte || 1 }
: { titre: '', date_renc: '', heure: '', lieu: '', description: '', places: 30, participants_par_compte: 1 }
);
const [image, setImage] = useState(renc?.image_path || '');
const [error, setError] = useState('');
const onChange = (e) => setForm({ ...form, [e.target.name]: e.target.value });
const submit = async (e) => {
e.preventDefault();
setError('');
try {
if (renc) await api.put(`/api/admin/rencontres/${renc.id}`, form);
else await api.post('/api/admin/rencontres', form);
onSaved();
} catch (err) {
setError(err.message);
}
};
const remove = async () => {
if (!window.confirm('Supprimer cette rencontre et toutes ses inscriptions ?')) return;
try {
await api.del(`/api/admin/rencontres/${renc.id}`);
onSaved();
} catch (err) {
setError(err.message);
}
};
return (
);
}
function escHtml(v) {
return String(v ?? '').replace(/&/g, '&').replace(//g, '>');
}
// Composer + preview of the ready-to-send HTML invitation email generated
// server-side for one rencontre. Every text zone (salutation, intro,
// conclusion, signature) is editable — the preview regenerates as the
// admin types; clearing a zone removes its block from the email.
// "Copier l'email" puts the rendered HTML on the clipboard (rich text
// where supported, so pasting into a mail client keeps the design); the
// .html download covers the rest.
function EmailModal({ renc, onClose }) {
const [texts, setTexts] = useState(null); // editable zones, prefilled by the server
const [data, setData] = useState(null); // { subject, html, inscriptionUrl }
const [error, setError] = useState('');
const [copied, setCopied] = useState('');
const skipRegen = useRef(true);
const generate = (t) =>
api.post(`/api/admin/rencontres/${renc.id}/email`, {
// The browser knows the site's real public origin (scheme included) —
// the server can't always tell behind a reverse proxy.
base: window.location.origin,
...(t || {}),
});
useEffect(() => {
generate(null)
.then((d) => {
setData(d);
skipRegen.current = true;
setTexts(d.texts);
})
.catch((e) => setError(e.message));
}, [renc.id]);
// Regenerate the preview as the admin edits, debounced.
useEffect(() => {
if (!texts) return;
if (skipRegen.current) {
skipRegen.current = false;
return;
}
const id = setTimeout(() => {
generate(texts).then(setData).catch((e) => setError(e.message));
}, 450);
return () => clearTimeout(id);
}, [texts]);
const onText = (e) => setTexts({ ...texts, [e.target.name]: e.target.value });
const flash = (what) => {
setCopied(what);
setTimeout(() => setCopied(''), 1800);
};
const copyEmail = async () => {
setError('');
try {
await copyRichEmail(data.html, `${data.subject}\n${data.inscriptionUrl}`);
flash('email');
} catch {
setError('Copie impossible dans ce navigateur — utilisez le téléchargement.');
}
};
const copyText = async (text, what) => {
setError('');
try {
await navigator.clipboard.writeText(text);
flash(what);
} catch {
setError('Copie impossible dans ce navigateur.');
}
};
const download = () => {
const blob = new Blob([data.html], { type: 'text/html' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
const slug = renc.titre.normalize('NFD').replace(/[̀-ͯ]/g, '').replace(/[^a-zA-Z0-9]+/g, '-').toLowerCase();
a.href = url;
a.download = `invitation-${slug}.html`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(url), 2000);
};
return (
{!data && !error &&
Génération de l'email…
}
{error &&
{error}
}
{data && texts && (
Les détails de la rencontre (titre, description, date, lieu, places) sont repris
automatiquement. Videz un champ pour retirer ce bloc de l'email.
Collez l'email directement dans votre logiciel de messagerie (« Copier l'email »
conserve la mise en forme), ou envoyez le fichier .html avec votre outil d'emailing.
Objet : {data.subject}
)}
);
}
function ParticipantsModal({ renc, onClose, onEdit, onCancel, refreshKey }) {
const { user } = useAuth();
const [rows, setRows] = useState([]);
const { jour, mois } = dateParts(renc.date_renc);
const meta = `${jour} ${mois} · ${renc.heure} · ${renc.lieu}`;
useEffect(() => {
api.get(`/api/admin/rencontres/${renc.id}/inscriptions`).then((d) => setRows(d.inscriptions)).catch(() => {});
}, [renc.id, refreshKey]);
const exportExcel = () => {
let html = '