Ajoute le suivi des demandes d'adhésion

This commit is contained in:
Michael committed 2026-07-15 11:02:13 +02:00
1 parent b4a3019098
commit c49c45e65f
6 files changed
+291 -7

No files matched your search

+4 -1
View File
@@ -115,7 +115,7 @@ l'utilisateur est bloqué sur un écran de changement obligatoire avant d'accéd
**Rôles du back-office** **Rôles du back-office**
- **Administrateur** : accès complet — tableau de bord, membres, rencontres, inscriptions, - **Administrateur** : accès complet — tableau de bord, membres, rencontres, inscriptions,
catégories, contenu du site, et gestion des comptes administrateurs/modérateurs. catégories, contenu du site, et gestion des comptes administrateurs/modérateurs.
- **Modérateur** : accès aux membres, aux rencontres, aux rencontres passées et aux - **Modérateur** : accès aux membres, aux demandes d'adhésion, aux rencontres, aux rencontres passées et aux
inscriptions (opérations du quotidien : créer, éditer) ; pas de tableau de bord, pas de inscriptions (opérations du quotidien : créer, éditer) ; pas de tableau de bord, pas de
catégories, pas de contenu du site, pas de gestion des comptes. Aucune action destructrice catégories, pas de contenu du site, pas de gestion des comptes. Aucune action destructrice
n'est autorisée à un modérateur : il ne peut ni valider/suspendre un membre, ni supprimer n'est autorisée à un modérateur : il ne peut ni valider/suspendre un membre, ni supprimer
@@ -134,6 +134,9 @@ l'utilisateur est bloqué sur un écran de changement obligatoire avant d'accéd
(1er sept. → 31 août). La création d'un membre avec email génère automatiquement un mot de (1er sept. → 31 août). La création d'un membre avec email génère automatiquement un mot de
passe temporaire, affiché à l'admin et copiable ; il reste visible dans la liste tant que le passe temporaire, affiché à l'admin et copiable ; il reste visible dans la liste tant que le
membre ne l'a pas changé. L'admin peut aussi réinitialiser l'accès à tout moment. membre ne l'a pas changé. L'admin peut aussi réinitialiser l'accès à tout moment.
- Demandes d'adhésion : suivi partagé entre administrateurs et modérateurs, avec étapes
« nouvelle », « contact pris » puis « membre validé ». La validation réactive la fiche
existante trouvée par email ou crée un nouveau membre et son accès temporaire.
- Rencontres : création, édition (avec photo affichée sur les cartes de la page d'accueil) ; - Rencontres : création, édition (avec photo affichée sur les cartes de la page d'accueil) ;
suppression réservée aux administrateurs. Liste des inscrits avec impression et export Excel. suppression réservée aux administrateurs. Liste des inscrits avec impression et export Excel.
- Rencontres passées : gestion complète de la section « Ils y étaient » et de la page publique - Rencontres passées : gestion complète de la section « Ils y étaient » et de la page publique
+108 -4
View File
@@ -1,5 +1,5 @@
import { Router } from 'express'; import { Router } from 'express';
import { query } from '../db.js'; import { pool, query } from '../db.js';
import { requireAuth } from '../middleware/auth.js'; import { requireAuth } from '../middleware/auth.js';
import { validate } from '../middleware/validate.js'; import { validate } from '../middleware/validate.js';
import { import {
@@ -552,16 +552,120 @@ adminRouter.post('/content/hero-photo', adminOnly, (req, res, next) => {
}); });
}); });
// ---------- Demandes d'adhésion (admin only) ---------- // ---------- Demandes d'adhésion (admin + moderator) ----------
adminRouter.get('/demandes', adminOnly, async (_req, res, next) => { adminRouter.get('/demandes', async (_req, res, next) => {
try { try {
const result = await query('SELECT * FROM demandes_adhesion ORDER BY created_at DESC'); const result = await query(`
SELECT d.*, m.nom AS membre_nom
FROM demandes_adhesion d
LEFT JOIN members m ON m.id = d.member_id
ORDER BY CASE d.statut WHEN 'nouvelle' THEN 0 WHEN 'contactee' THEN 1 ELSE 2 END,
d.created_at DESC`);
res.json({ demandes: result.rows }); res.json({ demandes: result.rows });
} catch (err) { } catch (err) {
next(err); next(err);
} }
}); });
adminRouter.post('/demandes/:id/contact', validate(idParam, 'params'), async (req, res, next) => {
try {
const result = await query(
`UPDATE demandes_adhesion
SET statut = 'contactee', contacted_at = COALESCE(contacted_at, now())
WHERE id = $1 AND statut = 'nouvelle'
RETURNING *`,
[req.params.id]
);
if (result.rowCount === 0) {
const existing = await query('SELECT statut FROM demandes_adhesion WHERE id = $1', [req.params.id]);
if (existing.rowCount === 0) return res.status(404).json({ error: 'Demande introuvable' });
return res.status(409).json({ error: 'Cette demande a déjà été prise en charge.' });
}
res.json({ demande: result.rows[0] });
} catch (err) {
next(err);
}
});
adminRouter.post('/demandes/:id/validate', validate(idParam, 'params'), async (req, res, next) => {
let client;
try {
client = await pool.connect();
await client.query('BEGIN');
const request = await client.query(
'SELECT * FROM demandes_adhesion WHERE id = $1 FOR UPDATE',
[req.params.id]
);
if (request.rowCount === 0) {
await client.query('ROLLBACK');
return res.status(404).json({ error: 'Demande introuvable' });
}
const d = request.rows[0];
if (d.statut !== 'contactee') {
await client.query('ROLLBACK');
return res.status(409).json({
error: d.statut === 'validee'
? 'Cette demande a déjà été validée.'
: "Validez d'abord la prise de contact.",
});
}
if (!d.email) {
await client.query('ROLLBACK');
return res.status(400).json({ error: 'Une adresse email est nécessaire pour créer le compte membre.' });
}
let member = await client.query(
'SELECT id FROM members WHERE email = $1 ORDER BY id LIMIT 1 FOR UPDATE',
[d.email]
);
let memberId;
if (member.rowCount > 0) {
memberId = member.rows[0].id;
await client.query(
`UPDATE members
SET valide = true, adhesion = $1, dirigeant = CASE WHEN dirigeant = '' THEN $2 ELSE dirigeant END,
tel = COALESCE(tel, $3), updated_at = now()
WHERE id = $4`,
[new Date().getFullYear(), d.nom, d.tel, memberId]
);
} else {
member = await client.query(
`INSERT INTO members (nom, secteur, dirigeant, adhesion, email, tel, presentation, valide)
VALUES ($1, '', $2, $3, $4, $5, '', true) RETURNING id`,
[d.entreprise, d.nom, new Date().getFullYear(), d.email, d.tel]
);
memberId = member.rows[0].id;
}
const login = await client.query('SELECT id FROM users WHERE member_id = $1', [memberId]);
await client.query(
`UPDATE demandes_adhesion
SET statut = 'validee', contacted_at = COALESCE(contacted_at, now()),
validated_at = now(), member_id = $1
WHERE id = $2`,
[memberId, d.id]
);
await client.query('COMMIT');
let tempPassword = null;
let accessError = null;
if (login.rowCount === 0) {
try {
tempPassword = await ensureMemberAccess(memberId, d.email);
} catch (err) {
if (!(err instanceof AccessError)) throw err;
accessError = err.message;
}
}
res.json({ memberId, email: d.email, tempPassword, accessError });
} catch (err) {
if (client) await client.query('ROLLBACK').catch(() => {});
next(err);
} finally {
client?.release();
}
});
// ---------- Admin & moderator accounts (admin only) ---------- // ---------- Admin & moderator accounts (admin only) ----------
adminRouter.get('/users', adminOnly, async (req, res, next) => { adminRouter.get('/users', adminOnly, async (req, res, next) => {
try { try {
+12 -1
View File
@@ -136,10 +136,21 @@ CREATE TABLE IF NOT EXISTS demandes_adhesion (
entreprise TEXT NOT NULL CHECK (char_length(entreprise) BETWEEN 1 AND 120), entreprise TEXT NOT NULL CHECK (char_length(entreprise) BETWEEN 1 AND 120),
email CITEXT NOT NULL CHECK (char_length(email) <= 254), email CITEXT NOT NULL CHECK (char_length(email) <= 254),
tel TEXT CHECK (char_length(tel) <= 30), tel TEXT CHECK (char_length(tel) <= 30),
statut TEXT NOT NULL DEFAULT 'nouvelle' CHECK (statut IN ('nouvelle', 'traitee')), statut TEXT NOT NULL DEFAULT 'nouvelle' CHECK (statut IN ('nouvelle', 'contactee', 'validee')),
contacted_at TIMESTAMPTZ,
validated_at TIMESTAMPTZ,
member_id INTEGER REFERENCES members(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now() created_at TIMESTAMPTZ NOT NULL DEFAULT now()
); );
ALTER TABLE demandes_adhesion ADD COLUMN IF NOT EXISTS tel TEXT CHECK (char_length(tel) <= 30); ALTER TABLE demandes_adhesion ADD COLUMN IF NOT EXISTS tel TEXT CHECK (char_length(tel) <= 30);
ALTER TABLE demandes_adhesion ADD COLUMN IF NOT EXISTS contacted_at TIMESTAMPTZ;
ALTER TABLE demandes_adhesion ADD COLUMN IF NOT EXISTS validated_at TIMESTAMPTZ;
ALTER TABLE demandes_adhesion ADD COLUMN IF NOT EXISTS member_id INTEGER REFERENCES members(id) ON DELETE SET NULL;
ALTER TABLE demandes_adhesion DROP CONSTRAINT IF EXISTS demandes_adhesion_statut_check;
UPDATE demandes_adhesion SET statut = 'contactee', contacted_at = COALESCE(contacted_at, created_at)
WHERE statut = 'traitee';
ALTER TABLE demandes_adhesion ADD CONSTRAINT demandes_adhesion_statut_check
CHECK (statut IN ('nouvelle', 'contactee', 'validee'));
CREATE TABLE IF NOT EXISTS site_content ( CREATE TABLE IF NOT EXISTS site_content (
key TEXT PRIMARY KEY, key TEXT PRIMARY KEY,
+124
View File
@@ -0,0 +1,124 @@
import { useEffect, useMemo, useState } from 'react';
import { api } from '../../lib/api.js';
import { CredentialsModal } from './AccessControls.jsx';
const STATUS = {
nouvelle: { label: 'Nouvelle', className: 'badge-red' },
contactee: { label: 'Contact pris', className: 'badge-amber' },
validee: { label: 'Membre validé', className: 'badge-green' },
};
const formatDate = (value) => new Date(value).toLocaleDateString('fr-FR', {
day: '2-digit', month: 'short', year: 'numeric',
});
export function RequestsTab() {
const [requests, setRequests] = useState([]);
const [filter, setFilter] = useState('pending');
const [busyId, setBusyId] = useState(null);
const [error, setError] = useState('');
const [credentials, setCredentials] = useState(null);
const reload = () => api.get('/api/admin/demandes')
.then((data) => setRequests(data.demandes))
.catch((err) => setError(err.message));
useEffect(() => { reload(); }, []);
const visible = useMemo(() => requests.filter((request) => (
filter === 'completed' ? request.statut === 'validee' : request.statut !== 'validee'
)), [filter, requests]);
const pendingCount = requests.filter((request) => request.statut !== 'validee').length;
const newCount = requests.filter((request) => request.statut === 'nouvelle').length;
const run = async (request, action) => {
setBusyId(request.id);
setError('');
try {
const result = await api.post(`/api/admin/demandes/${request.id}/${action}`);
if (result.tempPassword) {
setCredentials({ recipient: result.email, tempPassword: result.tempPassword });
} else if (result.accessError) {
setError(`Membre validé, mais accès non créé : ${result.accessError}`);
}
await reload();
} catch (err) {
setError(err.message);
} finally {
setBusyId(null);
}
};
return (
<div className="requests-layout">
<div className="requests-summary">
<div className="card request-stat"><span>À traiter</span><strong>{pendingCount}</strong></div>
<div className="card request-stat request-stat-accent"><span>Nouvelles</span><strong>{newCount}</strong></div>
<div className="card request-stat"><span>Validées</span><strong>{requests.length - pendingCount}</strong></div>
</div>
<section className="card requests-panel">
<div className="requests-toolbar">
<div>
<h3 className="serif">Demandes d’adhésion</h3>
<p>Contactez chaque personne avant validation membre.</p>
</div>
<div className="requests-filter" aria-label="Filtrer les demandes">
<button className={filter === 'pending' ? 'active' : ''} onClick={() => setFilter('pending')}>En cours</button>
<button className={filter === 'completed' ? 'active' : ''} onClick={() => setFilter('completed')}>Terminées</button>
</div>
</div>
{error && <p className="error-text requests-error">{error}</p>}
<div className="requests-list">
{visible.map((request) => {
const status = STATUS[request.statut] || STATUS.nouvelle;
const busy = busyId === request.id;
return (
<article className="request-row" key={request.id}>
<div className="request-date"><span>Reçue</span><strong>{formatDate(request.created_at)}</strong></div>
<div className="request-identity">
<div className="request-title-line">
<h4>{request.nom}</h4>
<span className={`badge ${status.className}`}>{status.label}</span>
</div>
<p>{request.fonction || 'Fonction non précisée'} · <strong>{request.entreprise}</strong></p>
<div className="request-contact">
<a href={`mailto:${request.email}`}>{request.email}</a>
{request.tel && <a href={`tel:${request.tel}`}>{request.tel}</a>}
</div>
</div>
<div className="request-actions">
{request.statut === 'nouvelle' && (
<button className="btn btn-outline-soft btn-sm" disabled={busy} onClick={() => run(request, 'contact')}>
{busy ? 'Enregistrement…' : '✓ Contact pris'}
</button>
)}
{request.statut === 'contactee' && (
<button className="btn btn-red btn-sm" disabled={busy || !request.email} onClick={() => run(request, 'validate')}>
{busy ? 'Validation…' : 'Valider comme membre'}
</button>
)}
{request.statut === 'validee' && (
<span className="request-member-link">Membre : {request.membre_nom || request.entreprise}</span>
)}
</div>
</article>
);
})}
{visible.length === 0 && (
<div className="requests-empty">
<span>✓</span>
<h4>Aucune demande {filter === 'pending' ? 'en cours' : 'terminée'}</h4>
<p>Les nouvelles demandes apparaîtront ici.</p>
</div>
)}
</div>
</section>
{credentials && (
<CredentialsModal recipient={credentials.recipient} tempPassword={credentials.tempPassword} onClose={() => setCredentials(null)} />
)}
</div>
);
}
+4 -1
View File
@@ -7,14 +7,16 @@ import { CategoriesTab, ContenuTab } from '../components/admin/AdminContent.jsx'
import { PastEventsTab } from '../components/admin/AdminPast.jsx'; import { PastEventsTab } from '../components/admin/AdminPast.jsx';
import { UsersTab } from '../components/admin/AdminUsers.jsx'; import { UsersTab } from '../components/admin/AdminUsers.jsx';
import { EmailCreatorTab } from '../components/admin/AdminEmail.jsx'; import { EmailCreatorTab } from '../components/admin/AdminEmail.jsx';
import { RequestsTab } from '../components/admin/AdminRequests.jsx';
// Moderators only get members/rencontres/inscriptions/rencontres passées // Moderators get members/membership requests/rencontres/inscriptions/rencontres passées
// (create + edit, never delete) — everything else (dashboard, taxonomy, // (create + edit, never delete) — everything else (dashboard, taxonomy,
// site content, staff account management) is admin-only, enforced both // site content, staff account management) is admin-only, enforced both
// here (sidebar) and server-side (routes). // here (sidebar) and server-side (routes).
const TABS = [ const TABS = [
{ key: 'dashboard', icon: '◧', label: 'Tableau de bord', title: 'Tableau de bord', roles: ['admin'] }, { key: 'dashboard', icon: '◧', label: 'Tableau de bord', title: 'Tableau de bord', roles: ['admin'] },
{ key: 'membres', icon: '▤', label: 'Membres', title: 'Gestion des membres', roles: ['admin', 'moderator'] }, { key: 'membres', icon: '▤', label: 'Membres', title: 'Gestion des membres', roles: ['admin', 'moderator'] },
{ key: 'demandes', icon: '◌', label: 'Demandes', title: "Demandes d’adhésion", roles: ['admin', 'moderator'] },
{ key: 'rencontres', icon: '◈', label: 'Rencontres', title: 'Rencontres', roles: ['admin', 'moderator'] }, { key: 'rencontres', icon: '◈', label: 'Rencontres', title: 'Rencontres', roles: ['admin', 'moderator'] },
{ key: 'passees', icon: '▦', label: 'Rencontres passées', title: 'Rencontres passées', roles: ['admin', 'moderator'] }, { key: 'passees', icon: '▦', label: 'Rencontres passées', title: 'Rencontres passées', roles: ['admin', 'moderator'] },
{ key: 'inscriptions', icon: '✎', label: 'Inscriptions', title: 'Inscriptions', roles: ['admin', 'moderator'] }, { key: 'inscriptions', icon: '✎', label: 'Inscriptions', title: 'Inscriptions', roles: ['admin', 'moderator'] },
@@ -147,6 +149,7 @@ export function AdminShell() {
<div className="admin-content-body" style={{ padding: '36px 40px' }}> <div className="admin-content-body" style={{ padding: '36px 40px' }}>
{tab === 'dashboard' && <Dashboard />} {tab === 'dashboard' && <Dashboard />}
{tab === 'membres' && <MembersTab />} {tab === 'membres' && <MembersTab />}
{tab === 'demandes' && <RequestsTab />}
{tab === 'rencontres' && <RencontresTab />} {tab === 'rencontres' && <RencontresTab />}
{tab === 'passees' && <PastEventsTab />} {tab === 'passees' && <PastEventsTab />}
{tab === 'inscriptions' && <InscriptionsTab />} {tab === 'inscriptions' && <InscriptionsTab />}
+39
View File
@@ -124,6 +124,41 @@ input:focus, textarea:focus, select:focus { outline: none; border-color: var(--r
} }
.admin-nav-btn.active { background: rgba(193, 39, 45, .18); color: #fff; border-left-color: var(--red); font-weight: 600; } .admin-nav-btn.active { background: rgba(193, 39, 45, .18); color: #fff; border-left-color: var(--red); font-weight: 600; }
/* Membership request workflow */
.requests-layout { display: flex; flex-direction: column; gap: 20px; }
.requests-summary { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; }
.request-stat { padding: 20px 22px; border-radius: 6px; display: flex; align-items: flex-end; justify-content: space-between; }
.request-stat span { font-size: 12.5px; color: var(--gray-light); }
.request-stat strong { font-family: Georgia, serif; font-size: 32px; line-height: 1; }
.request-stat-accent { background: var(--dark); color: #fff; }
.request-stat-accent span { color: #B7AFA6; }
.requests-panel { border-radius: 6px; overflow: hidden; }
.requests-toolbar { padding: 22px 24px; display: flex; align-items: center; justify-content: space-between; gap: 20px; border-bottom: 1px solid var(--border); }
.requests-toolbar h3 { font-size: 20px; font-weight: 600; }
.requests-toolbar p { margin-top: 4px; color: var(--gray-light); font-size: 12.5px; }
.requests-filter { display: flex; padding: 3px; background: var(--admin-bg); border-radius: 5px; }
.requests-filter button { border: 0; background: transparent; color: var(--gray); padding: 8px 13px; border-radius: 3px; font-size: 12.5px; font-weight: 600; }
.requests-filter button.active { background: #fff; color: var(--ink); box-shadow: 0 1px 4px rgba(27,27,27,.1); }
.requests-error { margin: 14px 24px 0; }
.requests-list { display: flex; flex-direction: column; }
.request-row { display: grid; grid-template-columns: 110px minmax(260px, 1fr) auto; gap: 22px; align-items: center; padding: 22px 24px; border-top: 1px solid var(--border-soft); }
.request-row:first-child { border-top: 0; }
.request-row:hover { background: #FCFAF7; }
.request-date { display: flex; flex-direction: column; gap: 4px; color: var(--gray-light); font-size: 11px; text-transform: uppercase; letter-spacing: .08em; }
.request-date strong { color: var(--gray); font-size: 12px; letter-spacing: 0; text-transform: none; }
.request-title-line { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.request-title-line h4 { font-family: Georgia, serif; font-size: 18px; color: var(--ink); }
.request-identity > p { margin-top: 5px; color: var(--gray); font-size: 13px; }
.request-contact { display: flex; gap: 16px; flex-wrap: wrap; margin-top: 10px; }
.request-contact a { color: var(--red); font-size: 12.5px; text-decoration: none; font-weight: 600; }
.request-contact a:hover { text-decoration: underline; }
.request-actions { min-width: 170px; text-align: right; }
.request-member-link { color: var(--green); font-size: 12.5px; font-weight: 600; }
.requests-empty { padding: 60px 24px; text-align: center; color: var(--gray-light); }
.requests-empty > span { display: inline-flex; width: 44px; height: 44px; align-items: center; justify-content: center; border-radius: 50%; background: #E8F3EC; color: var(--green); font-size: 20px; }
.requests-empty h4 { margin-top: 14px; color: var(--ink); font-family: Georgia, serif; font-size: 18px; }
.requests-empty p { margin-top: 5px; font-size: 13px; }
/* Header nav */ /* Header nav */
.nav-btn { background: none; border: none; font-size: 14.5px; font-weight: 500; color: var(--gray-mid); padding: 10px 14px; border-radius: 2px; } .nav-btn { background: none; border: none; font-size: 14.5px; font-weight: 500; color: var(--gray-mid); padding: 10px 14px; border-radius: 2px; }
.nav-btn:hover, .nav-btn.active { color: var(--red); } .nav-btn:hover, .nav-btn.active { color: var(--red); }
@@ -157,6 +192,10 @@ input:focus, textarea:focus, select:focus { outline: none; border-color: var(--r
.admin-sidebar { width: 100% !important; height: auto !important; position: static !important; } .admin-sidebar { width: 100% !important; height: auto !important; position: static !important; }
.admin-content-header { padding: 16px 20px !important; } .admin-content-header { padding: 16px 20px !important; }
.admin-content-body { padding: 20px !important; } .admin-content-body { padding: 20px !important; }
.requests-summary { grid-template-columns: 1fr; }
.requests-toolbar { align-items: flex-start; flex-direction: column; }
.request-row { grid-template-columns: 1fr; gap: 12px; }
.request-actions { min-width: 0; text-align: left; }
} }
@media (max-width: 640px) { @media (max-width: 640px) {