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
Image
Accès
{members.map((m) => (
{m.nom}
{m.secteur}
{m.dirigeant}
{m.valide ? `Validé ${season}` : 'Non validé'}
{m.image_consent ? (
) : (
En attente
)}
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)}
/>
)}
{consentView && setConsentView(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 downloadOutlook = async () => {
const slug = renc.titre.normalize('NFD').replace(/[̀-ͯ]/g, '').replace(/[^a-zA-Z0-9]+/g, '-').toLowerCase();
try {
setError('');
await downloadOutlookDraft({
subject: data.subject,
html: data.html,
filename: `invitation-${slug}-outlook.eml`,
});
flash('outlook');
} catch (err) {
setError(err.message || "Impossible de créer le brouillon Outlook.");
}
};
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.
Pour Outlook, ouvrez le fichier .eml téléchargé : il charge directement le vrai corps HTML sans passer par le presse-papiers. La copie reste disponible pour les autres logiciels de messagerie.