Files
SBC/web/src/components/admin/AdminTabs.jsx
T
Claude 02cfa8f8bc Email d'invitation HTML généré depuis l'administration des rencontres
Chaque rencontre dispose d'un bouton « ✉ Email » dans l'administration
(admins et modérateurs) qui génère un email HTML prêt à envoyer, aux
couleurs du site avec le logo du club : titre, description, encadré
date/heure/lieu/places restantes et bouton d'inscription.

- Nouvelle route GET /api/admin/rencontres/:id/email : construit l'email
  (mise en page en tables + styles inline, compatible clients mail) avec
  des URL absolues dérivées de l'hôte de la requête.
- Le bouton de l'email pointe vers /?inscription=<id> : la page d'accueil
  ouvre alors automatiquement la modale d'inscription de la rencontre.
- Modale d'aperçu dans l'admin : copie de l'email (HTML enrichi presse-
  papiers pour coller directement dans un logiciel de messagerie), copie
  de l'objet et du lien d'inscription, téléchargement du fichier .html.
- Les images publiques (logo, uploads) sont maintenant servies avec
  Cross-Origin-Resource-Policy: cross-origin pour que le logo se charge
  dans les webmails et l'aperçu.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fkg15RCxNgUys4ru73He2o
2026-07-14 07:11:34 +00:00

780 lines
33 KiB
React
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.
import { useEffect, useState } from 'react';
import { api, dateParts, seasonLabel, statutLabel } from '../../lib/api.js';
import { useAuth } from '../../lib/AuthContext.jsx';
import Modal from '../Modal.jsx';
import ImageSlot from '../ImageSlot.jsx';
import { AccessCell, CredentialsModal } from './AccessControls.jsx';
/* ---------------- Members ---------------- */
function MemberFormModal({ member, categories, onClose, onSaved }) {
const [form, setForm] = useState(
member
? { ...member, email: member.email || '', tel: member.tel || '', site: member.site || '', presentation: member.presentation || '' }
: { nom: '', secteur: '', categorie_id: categories[0]?.id ?? null, dirigeant: '', email: '', tel: '', site: '', presentation: '' }
);
const [error, setError] = useState('');
const onChange = (e) => {
const { name, value } = e.target;
setForm({ ...form, [name]: name === 'categorie_id' ? (value ? Number(value) : null) : value });
};
const submit = async (e) => {
e.preventDefault();
setError('');
const body = {
nom: form.nom, secteur: form.secteur, categorie_id: form.categorie_id,
dirigeant: form.dirigeant, email: form.email, tel: form.tel,
site: form.site, presentation: form.presentation,
};
try {
if (member) {
await api.put(`/api/admin/members/${member.id}`, body);
onSaved();
} else {
const result = await api.post('/api/admin/members', body);
onSaved({ ...result, email: body.email });
}
} catch (err) {
setError(err.message);
}
};
return (
<Modal onClose={onClose} maxWidth={600} header={{ kicker: 'Membres', title: member ? 'Modifier le membre' : 'Nouveau membre' }}>
<form onSubmit={submit} style={{ padding: '26px 30px' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<label className="field">Entreprise
<input name="nom" value={form.nom} onChange={onChange} required maxLength={120} />
</label>
<label className="field">Catégorie
<select name="categorie_id" value={form.categorie_id || ''} onChange={onChange}>
<option value="">Non classée</option>
{categories.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
</label>
</div>
<label className="field">Secteur d'activité
<input name="secteur" value={form.secteur} onChange={onChange} required maxLength={120} />
</label>
<label className="field">Dirigeant·e
<input name="dirigeant" value={form.dirigeant} onChange={onChange} required maxLength={120} />
</label>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<label className="field">Email
<input name="email" type="email" value={form.email} onChange={onChange} maxLength={254} />
</label>
<label className="field">Téléphone
<input name="tel" value={form.tel} onChange={onChange} maxLength={30} />
</label>
</div>
<label className="field">Site web
<input name="site" value={form.site} onChange={onChange} maxLength={200} />
</label>
<label className="field">Présentation
<textarea name="presentation" value={form.presentation} onChange={onChange} rows={3} maxLength={2000} />
</label>
</div>
{error && <p className="error-text" style={{ marginTop: 12 }}>{error}</p>}
<div style={{ display: 'flex', gap: 12, marginTop: 24 }}>
<button type="submit" className="btn btn-red btn-sm" style={{ flex: 1, fontSize: 14.5, padding: 13 }}>
{member ? 'Enregistrer' : 'Ajouter le membre'}
</button>
<button type="button" className="btn btn-outline-soft btn-sm" style={{ fontSize: 14.5, padding: '13px 22px' }} onClick={onClose}>
Annuler
</button>
</div>
</form>
</Modal>
);
}
export function MembersTab() {
const { user } = useAuth();
const isAdmin = user.role === 'admin';
const [members, setMembers] = useState([]);
const [categories, setCategories] = useState([]);
const [modal, setModal] = useState(null); // null | 'new' | member
const [credentials, setCredentials] = useState(null); // { email, tempPassword }
const [accessError, setAccessError] = useState('');
const season = seasonLabel();
const reload = () =>
Promise.all([api.get('/api/admin/members'), api.get('/api/admin/categories')])
.then(([m, c]) => {
setMembers(m.members);
setCategories(c.categories);
})
.catch(() => {});
useEffect(() => {
reload();
}, []);
const toggle = async (m) => {
await api.post(`/api/admin/members/${m.id}/toggle-valide`);
reload();
};
const generateAccess = async (m) => {
setAccessError('');
try {
const d = await api.post(`/api/admin/members/${m.id}/reset-access`);
setCredentials({ recipient: m.email, tempPassword: d.tempPassword });
reload();
} catch (err) {
setAccessError(err.message);
}
};
const actifs = members.filter((m) => m.valide).length;
return (
<div className="card" style={{ borderRadius: 6, overflow: 'hidden' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '20px 24px', borderBottom: '1px solid var(--border)' }}>
<div>
<h3 className="serif" style={{ fontSize: 19, fontWeight: 600 }}>Membres · {actifs} validés</h3>
<div style={{ fontSize: 12.5, color: 'var(--gray-light)', marginTop: 3 }}>
Saison {season} · {members.length - actifs} à renouveler
</div>
</div>
<button className="btn btn-red btn-sm" style={{ fontSize: 13, padding: '10px 18px' }} onClick={() => setModal('new')}>
+ Ajouter un membre
</button>
</div>
<div style={{ display: 'flex', gap: 10, alignItems: 'flex-start', padding: '14px 24px', background: '#FBF6EE', borderBottom: '1px solid var(--border-soft)', fontSize: 12.5, color: '#8A6D3B', lineHeight: 1.5 }}>
<span>ⓘ</span>
<span>
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 1<sup>er</sup> septembre au 31 août.
</span>
</div>
{accessError && <p className="error-text" style={{ padding: '12px 24px 0' }}>{accessError}</p>}
<div style={{ overflowX: 'auto' }}>
<table className="table">
<thead>
<tr>
<th>Entreprise</th><th>Secteur</th><th>Dirigeant</th><th>Saison</th><th>Accès</th><th></th>
</tr>
</thead>
<tbody>
{members.map((m) => (
<tr key={m.id}>
<td>{m.nom}</td>
<td>{m.secteur}</td>
<td>{m.dirigeant}</td>
<td>
<span className={`badge ${m.valide ? 'badge-green' : 'badge-red'}`}>
{m.valide ? `Validé ${season}` : 'Non validé'}
</span>
</td>
<td>
<AccessCell
hasEmail={!!m.email}
hasLogin={m.has_login}
mustChangePassword={m.must_change_password}
tempPassword={m.temp_password}
onGenerate={() => generateAccess(m)}
/>
</td>
<td style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
{isAdmin && (
<button className="btn-link-gray" style={{ color: 'var(--gray)', marginRight: 14 }} onClick={() => toggle(m)}>
{m.valide ? 'Suspendre' : 'Valider'}
</button>
)}
<button className="btn-link" style={{ fontSize: 13 }} onClick={() => setModal(m)}>Modifier</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{modal && (
<MemberFormModal
member={modal === 'new' ? null : modal}
categories={categories}
onClose={() => 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 && (
<CredentialsModal
recipient={credentials.recipient}
tempPassword={credentials.tempPassword}
onClose={() => setCredentials(null)}
/>
)}
</div>
);
}
/* ---------------- 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 }
: { titre: '', date_renc: '', heure: '', lieu: '', description: '', places: 30 }
);
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 (
<Modal onClose={onClose} maxWidth={560} header={{ kicker: 'Rencontres', title: renc ? 'Gérer la rencontre' : 'Nouvelle rencontre' }}>
<form onSubmit={submit} style={{ padding: '26px 30px' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<div style={{ display: 'flex', gap: 16, alignItems: 'flex-start' }}>
<div style={{ width: 120, height: 84, flexShrink: 0 }}>
<ImageSlot
endpoint={renc ? `/api/admin/rencontres/${renc.id}/image` : undefined}
value={image}
disabled={!renc}
placeholder={renc ? 'Photo de la rencontre' : "Enregistrez d'abord la rencontre"}
onUploaded={setImage}
/>
</div>
<label className="field" style={{ flex: 1 }}>Titre de la rencontre
<input name="titre" value={form.titre} onChange={onChange} required maxLength={200} />
</label>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 12 }}>
<label className="field" style={{ gridColumn: 'span 2' }}>Date
<input name="date_renc" type="date" value={form.date_renc} onChange={onChange} required />
</label>
<label className="field">Heure
<input name="heure" value={form.heure} onChange={onChange} placeholder="18h30" maxLength={20} />
</label>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '2fr 1fr', gap: 12 }}>
<label className="field">Lieu
<input name="lieu" value={form.lieu} onChange={onChange} required maxLength={200} />
</label>
<label className="field">Places
<input name="places" type="number" min={0} max={100000} value={form.places} onChange={onChange} required />
</label>
</div>
<label className="field">Description
<textarea name="description" value={form.description} onChange={onChange} rows={3} maxLength={2000} />
</label>
</div>
{error && <p className="error-text" style={{ marginTop: 12 }}>{error}</p>}
<div style={{ display: 'flex', gap: 12, marginTop: 24, alignItems: 'center' }}>
<button type="submit" className="btn btn-red btn-sm" style={{ flex: 1, fontSize: 14.5, padding: 13 }}>
{renc ? 'Enregistrer' : 'Créer la rencontre'}
</button>
{renc && user.role === 'admin' && (
<button type="button" className="btn-link-gray" style={{ fontSize: 13.5, padding: '13px 10px' }} onClick={remove}>
Supprimer
</button>
)}
<button type="button" className="btn btn-outline-soft btn-sm" style={{ fontSize: 14.5, padding: '13px 22px' }} onClick={onClose}>
Annuler
</button>
</div>
</form>
</Modal>
);
}
function escHtml(v) {
return String(v ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
// Preview + export of the ready-to-send HTML invitation email generated
// server-side for one rencontre. "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 [data, setData] = useState(null);
const [error, setError] = useState('');
const [copied, setCopied] = useState('');
useEffect(() => {
api.get(`/api/admin/rencontres/${renc.id}/email`).then(setData).catch((e) => setError(e.message));
}, [renc.id]);
const flash = (what) => {
setCopied(what);
setTimeout(() => setCopied(''), 1800);
};
const copyEmail = async () => {
setError('');
try {
if (navigator.clipboard?.write && window.ClipboardItem) {
await navigator.clipboard.write([
new window.ClipboardItem({
'text/html': new Blob([data.html], { type: 'text/html' }),
'text/plain': new Blob([`${data.subject}\n${data.inscriptionUrl}`], { type: 'text/plain' }),
}),
]);
} else {
await navigator.clipboard.writeText(data.html);
}
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 (
<Modal onClose={onClose} maxWidth={760} header={{ kicker: "Email d'invitation", title: renc.titre }}>
{!data && !error && <p style={{ padding: '30px 32px', color: 'var(--gray-light)' }}>Génération de l'email…</p>}
{error && <p className="error-text" style={{ padding: '20px 32px 0' }}>{error}</p>}
{data && (
<>
<div style={{ padding: '18px 32px 0', display: 'flex', gap: 10, flexWrap: 'wrap', alignItems: 'center' }}>
<button className="btn btn-red btn-sm" style={{ fontSize: 13.5, padding: '11px 18px' }} onClick={copyEmail}>
{copied === 'email' ? '✓ Copié' : "Copier l'email"}
</button>
<button className="btn btn-sm" style={{ background: 'var(--admin-bg)', fontSize: 13.5, padding: '11px 18px' }} onClick={() => copyText(data.subject, 'subject')}>
{copied === 'subject' ? '✓ Copié' : "Copier l'objet"}
</button>
<button className="btn btn-sm" style={{ background: 'var(--admin-bg)', fontSize: 13.5, padding: '11px 18px' }} onClick={() => copyText(data.inscriptionUrl, 'link')}>
{copied === 'link' ? '✓ Copié' : "Copier le lien d'inscription"}
</button>
<button className="btn btn-outline-soft btn-sm" style={{ fontSize: 13.5, padding: '11px 18px' }} onClick={download}>
↓ Télécharger (.html)
</button>
</div>
<div style={{ padding: '14px 32px 6px', fontSize: 12.5, color: 'var(--gray-light)', lineHeight: 1.55 }}>
<strong style={{ color: 'var(--gray)' }}>Objet :</strong> {data.subject}
<br />
Collez l'email directement dans votre logiciel de messagerie (le bouton « Copier l'email »
conserve la mise en forme), ou envoyez le fichier .html avec votre outil d'emailing.
</div>
<div style={{ margin: '14px 32px 30px', border: '1px solid var(--border)', borderRadius: 6, overflow: 'hidden' }}>
<iframe title="Aperçu de l'email" srcDoc={data.html} sandbox="" style={{ display: 'block', width: '100%', height: 480, border: 'none', background: '#F2EEE8' }} />
</div>
</>
)}
</Modal>
);
}
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 = '<html xmlns:x="urn:schemas-microsoft-com:office:excel"><head><meta charset="utf-8"></head><body><table border="1"><thead>';
html += '<tr><th>Participant</th><th>Entreprise</th><th>Email</th><th>Téléphone</th><th>Rencontre</th><th>Statut</th></tr></thead><tbody>';
rows.forEach((p) => {
html += `<tr><td>${escHtml(p.nom)}</td><td>${escHtml(p.entreprise)}</td><td>${escHtml(p.email)}</td><td>${escHtml(p.tel)}</td><td>${escHtml(renc.titre)}</td><td>${escHtml(statutLabel(p.statut))}</td></tr>`;
});
html += '</tbody></table></body></html>';
const blob = new Blob(['' + html], { type: 'application/vnd.ms-excel' });
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 = `inscrits-${slug}.xls`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(url), 2000);
};
const print = () => {
let body = '';
rows.forEach((p, i) => {
body += `<tr><td>${i + 1}</td><td>${escHtml(p.nom)}</td><td>${escHtml(p.entreprise)}</td><td>${escHtml(p.email)}</td><td>${escHtml(p.tel)}</td><td>${escHtml(statutLabel(p.statut))}</td></tr>`;
});
const doc = `<!doctype html><html><head><meta charset="utf-8"><title>Inscrits — ${escHtml(renc.titre)}</title>`
+ '<style>body{font-family:Arial,Helvetica,sans-serif;color:#1B1B1B;padding:32px}h1{font-size:22px;margin:0 0 4px}.meta{color:#666;font-size:13px;margin-bottom:20px}'
+ 'table{width:100%;border-collapse:collapse;font-size:13px}th,td{text-align:left;padding:9px 10px;border-bottom:1px solid #ddd}th{background:#f4f1ec;text-transform:uppercase;font-size:11px;letter-spacing:.05em}'
+ '.brand{color:#C1272D;font-weight:700;font-size:12px;letter-spacing:.1em;text-transform:uppercase;margin-bottom:16px}</style></head><body>'
+ '<div class="brand">Business Club SLUC Nancy</div>'
+ `<h1>${escHtml(renc.titre)}</h1><div class="meta">${escHtml(meta)} — ${rows.length} inscrits</div>`
+ '<table><thead><tr><th>#</th><th>Participant</th><th>Entreprise</th><th>Email</th><th>Téléphone</th><th>Statut</th></tr></thead><tbody>'
+ body + '</tbody></table></body></html>';
const w = window.open('', '_blank');
if (!w) return;
w.document.open();
w.document.write(doc);
w.document.close();
setTimeout(() => { w.focus(); w.print(); }, 300);
};
return (
<Modal onClose={onClose} maxWidth={720} header={{ kicker: 'Liste des inscrits', title: renc.titre, meta: `${meta} · ${rows.length} inscrits` }}>
<div style={{ padding: '22px 32px', display: 'flex', gap: 12, borderBottom: '1px solid var(--border-soft)' }}>
<button className="btn btn-sm" style={{ background: 'var(--admin-bg)', color: 'var(--ink)', fontSize: 13.5, padding: '11px 18px' }} onClick={print}>
⎙ Imprimer
</button>
<button className="btn btn-sm" style={{ background: '#1E7B45', color: '#fff', fontSize: 13.5, padding: '11px 18px' }} onClick={exportExcel}>
↓ Exporter Excel
</button>
</div>
<div style={{ padding: '6px 0 12px' }}>
<table className="table">
<thead>
<tr><th>Participant</th><th>Entreprise</th><th>Email</th><th>Statut</th><th></th></tr>
</thead>
<tbody>
{rows.map((p) => (
<tr key={p.id}>
<td>{p.nom}</td>
<td>{p.entreprise}</td>
<td>{p.email}</td>
<td><span className={`badge ${p.statut === 'confirmee' ? 'badge-green' : 'badge-amber'}`}>{statutLabel(p.statut)}</span></td>
<td style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
<button className="btn-link" style={{ fontSize: 12.5, marginRight: 12 }} onClick={() => onEdit(p)}>Modifier</button>
{user.role === 'admin' && (
<button className="btn-link-gray" style={{ fontSize: 12.5 }} onClick={() => onCancel(p)}>Annuler</button>
)}
</td>
</tr>
))}
</tbody>
</table>
{rows.length === 0 && (
<div style={{ textAlign: 'center', padding: '40px 20px', color: 'var(--gray-light)', fontSize: 14 }}>
Aucun inscrit pour cette rencontre.
</div>
)}
</div>
</Modal>
);
}
export function RencontresTab() {
const [rencontres, setRencontres] = useState([]);
const [formModal, setFormModal] = useState(null); // null | 'new' | renc
const [participantsRenc, setParticipantsRenc] = useState(null);
const [emailRenc, setEmailRenc] = useState(null);
const [editInscr, setEditInscr] = useState(null);
const [cancelInscr, setCancelInscr] = useState(null);
const [refreshKey, setRefreshKey] = useState(0);
const reload = () => {
api.get('/api/admin/rencontres').then((d) => setRencontres(d.rencontres)).catch(() => {});
setRefreshKey((k) => k + 1);
};
useEffect(() => {
reload();
}, []);
return (
<>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 22 }}>
<h3 className="serif" style={{ fontSize: 20, fontWeight: 600 }}>Gestion des rencontres</h3>
<button className="btn btn-red btn-sm" style={{ fontSize: 13, padding: '10px 18px' }} onClick={() => setFormModal('new')}>
+ Créer une rencontre
</button>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
{rencontres.map((e) => {
const { jour, mois } = dateParts(e.date_renc);
return (
<div key={e.id} className="card" style={{ borderRadius: 6, padding: '22px 24px', display: 'grid', gridTemplateColumns: 'auto 1fr auto auto', gap: 24, alignItems: 'center' }}>
<div style={{ width: 56, textAlign: 'center', background: 'var(--admin-bg)', borderRadius: 5, padding: '10px 0', lineHeight: 1 }}>
<div className="serif" style={{ fontSize: 24, fontWeight: 600 }}>{jour}</div>
<div style={{ fontSize: 10, textTransform: 'uppercase', color: 'var(--gray-light)', letterSpacing: '.1em' }}>{mois}</div>
</div>
<div>
<div style={{ fontSize: 16, fontWeight: 600 }}>{e.titre}</div>
<div style={{ fontSize: 13, color: 'var(--gray-light)', marginTop: 4 }}>{e.heure} · {e.lieu}</div>
</div>
<div style={{ textAlign: 'center' }}>
<div className="serif" style={{ fontSize: 22, fontWeight: 600, color: 'var(--red)' }}>{e.inscrits}</div>
<div style={{ fontSize: 11, color: 'var(--gray-light)' }}>inscrits / {e.places}</div>
</div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<button className="btn btn-dark btn-sm" style={{ fontSize: 13, padding: '9px 16px' }} onClick={() => setParticipantsRenc(e)}>
Inscrits ({e.inscrits})
</button>
<button className="btn btn-sm" style={{ background: 'var(--admin-bg)', fontSize: 13, padding: '9px 16px' }} onClick={() => setEmailRenc(e)} title="Générer l'email d'invitation">
✉ Email
</button>
<button className="btn btn-sm" style={{ background: 'var(--admin-bg)', fontSize: 13, padding: '9px 16px' }} onClick={() => setFormModal(e)}>
Gérer
</button>
</div>
</div>
);
})}
</div>
{formModal && (
<RencFormModal
renc={formModal === 'new' ? null : formModal}
onClose={() => setFormModal(null)}
onSaved={() => { setFormModal(null); reload(); }}
/>
)}
{participantsRenc && (
<ParticipantsModal
renc={participantsRenc}
refreshKey={refreshKey}
onClose={() => setParticipantsRenc(null)}
onEdit={setEditInscr}
onCancel={setCancelInscr}
/>
)}
{emailRenc && <EmailModal renc={emailRenc} onClose={() => setEmailRenc(null)} />}
{editInscr && (
<InscriptionEditModal
inscription={editInscr}
rencontres={rencontres}
onClose={() => setEditInscr(null)}
onSaved={() => { setEditInscr(null); reload(); }}
onRequestCancel={(p) => { setEditInscr(null); setCancelInscr(p); }}
/>
)}
{cancelInscr && (
<CancelConfirmModal
inscription={cancelInscr}
onClose={() => setCancelInscr(null)}
onDone={() => { setCancelInscr(null); reload(); }}
/>
)}
</>
);
}
/* ---------------- Inscriptions ---------------- */
function InscriptionEditModal({ inscription, rencontres, onClose, onSaved, onRequestCancel }) {
const { user } = useAuth();
const [form, setForm] = useState({
nom: inscription.nom,
entreprise: inscription.entreprise,
email: inscription.email || '',
tel: inscription.tel || '',
rencontre_id: inscription.rencontre_id,
statut: inscription.statut,
});
const [error, setError] = useState('');
const onChange = (e) => {
const { name, value } = e.target;
setForm({ ...form, [name]: name === 'rencontre_id' ? Number(value) : value });
};
const submit = async (e) => {
e.preventDefault();
setError('');
try {
await api.put(`/api/admin/inscriptions/${inscription.id}`, form);
onSaved();
} catch (err) {
setError(err.message);
}
};
return (
<Modal onClose={onClose} maxWidth={520} header={{ kicker: 'Inscription', title: "Modifier l'inscription" }}>
<form onSubmit={submit} style={{ padding: '26px 30px' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<label className="field">Participant
<input name="nom" value={form.nom} onChange={onChange} required maxLength={120} />
</label>
<label className="field">Entreprise
<input name="entreprise" value={form.entreprise} onChange={onChange} required maxLength={120} />
</label>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<label className="field">Email
<input name="email" type="email" value={form.email} onChange={onChange} maxLength={254} />
</label>
<label className="field">Téléphone
<input name="tel" value={form.tel} onChange={onChange} maxLength={30} />
</label>
</div>
<label className="field">Rencontre
<select name="rencontre_id" value={form.rencontre_id} onChange={onChange}>
{rencontres.map((r) => <option key={r.id} value={r.id}>{r.titre}</option>)}
</select>
</label>
<label className="field">Statut
<select name="statut" value={form.statut} onChange={onChange}>
<option value="confirmee">Confirmée</option>
<option value="en_attente">En attente</option>
</select>
</label>
</div>
{error && <p className="error-text" style={{ marginTop: 12 }}>{error}</p>}
<div style={{ display: 'flex', gap: 12, marginTop: 24, alignItems: 'center' }}>
<button type="submit" className="btn btn-red btn-sm" style={{ flex: 1, fontSize: 14.5, padding: 13 }}>Enregistrer</button>
{user.role === 'admin' && (
<button type="button" className="btn-link-gray" style={{ fontSize: 13.5, padding: '13px 10px' }} onClick={() => onRequestCancel(inscription)}>
Annuler l'inscription
</button>
)}
<button type="button" className="btn btn-outline-soft btn-sm" style={{ fontSize: 14.5, padding: '13px 22px' }} onClick={onClose}>
Fermer
</button>
</div>
</form>
</Modal>
);
}
function CancelConfirmModal({ inscription, onClose, onDone }) {
const [error, setError] = useState('');
const confirm = async () => {
try {
await api.del(`/api/admin/inscriptions/${inscription.id}`);
onDone();
} catch (err) {
setError(err.message);
}
};
return (
<Modal onClose={onClose} maxWidth={440}>
<div style={{ padding: '36px 34px', textAlign: 'center' }}>
<div style={{ width: 56, height: 56, borderRadius: '50%', background: '#F6E4E4', color: 'var(--red)', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 26, margin: '0 auto 20px' }}>⚠</div>
<h3 className="serif" style={{ fontSize: 24, fontWeight: 600, marginBottom: 12 }}>Annuler cette inscription ?</h3>
<p style={{ fontSize: 15, color: 'var(--gray)', lineHeight: 1.6, marginBottom: 6 }}>
Retirer <strong>{inscription.nom}</strong> de la liste
</p>
<p style={{ fontSize: 14, color: 'var(--gray-light)', marginBottom: 28 }}>{inscription.rencontre}</p>
{error && <p className="error-text" style={{ marginBottom: 12 }}>{error}</p>}
<div style={{ display: 'flex', gap: 12 }}>
<button className="btn btn-red btn-sm" style={{ flex: 1, fontSize: 14.5, padding: 13 }} onClick={confirm}>
Annuler l'inscription
</button>
<button className="btn btn-outline-soft btn-sm" style={{ flex: 1, fontSize: 14.5, padding: 13 }} onClick={onClose}>
Retour
</button>
</div>
</div>
</Modal>
);
}
export function InscriptionsTab() {
const { user } = useAuth();
const [inscriptions, setInscriptions] = useState([]);
const [rencontres, setRencontres] = useState([]);
const [edit, setEdit] = useState(null);
const [cancel, setCancel] = useState(null);
const reload = () =>
Promise.all([api.get('/api/admin/inscriptions'), api.get('/api/admin/rencontres')])
.then(([i, r]) => {
setInscriptions(i.inscriptions);
setRencontres(r.rencontres);
})
.catch(() => {});
useEffect(() => {
reload();
}, []);
return (
<div className="card" style={{ borderRadius: 6, overflow: 'hidden' }}>
<div style={{ padding: '20px 24px', borderBottom: '1px solid var(--border)' }}>
<h3 className="serif" style={{ fontSize: 19, fontWeight: 600 }}>Inscriptions aux rencontres</h3>
</div>
<div style={{ overflowX: 'auto' }}>
<table className="table">
<thead>
<tr><th>Participant</th><th>Entreprise</th><th>Rencontre</th><th>Statut</th><th></th></tr>
</thead>
<tbody>
{inscriptions.map((i) => (
<tr key={i.id}>
<td>{i.nom}</td>
<td>{i.entreprise}</td>
<td>{i.rencontre}</td>
<td><span className={`badge ${i.statut === 'confirmee' ? 'badge-green' : 'badge-amber'}`}>{statutLabel(i.statut)}</span></td>
<td style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
<button className="btn-link" style={{ fontSize: 13, marginRight: 14 }} onClick={() => setEdit(i)}>Modifier</button>
{user.role === 'admin' && (
<button className="btn-link-gray" onClick={() => setCancel(i)}>Annuler</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
{edit && (
<InscriptionEditModal
inscription={edit}
rencontres={rencontres}
onClose={() => setEdit(null)}
onSaved={() => { setEdit(null); reload(); }}
onRequestCancel={(p) => { setEdit(null); setCancel(p); }}
/>
)}
{cancel && (
<CancelConfirmModal
inscription={cancel}
onClose={() => setCancel(null)}
onDone={() => { setCancel(null); reload(); }}
/>
)}
</div>
);
}