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**
- **Administrateur** : accès complet — tableau de bord, membres, rencontres, inscriptions,
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
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
@@ -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
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.
- 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) ;
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
+108 -4
View File
@@ -1,5 +1,5 @@
import { Router } from 'express';
import { query } from '../db.js';
import { pool, query } from '../db.js';
import { requireAuth } from '../middleware/auth.js';
import { validate } from '../middleware/validate.js';
import {
@@ -552,16 +552,120 @@ adminRouter.post('/content/hero-photo', adminOnly, (req, res, next) => {
});
});
// ---------- Demandes d'adhésion (admin only) ----------
adminRouter.get('/demandes', adminOnly, async (_req, res, next) => {
// ---------- Demandes d'adhésion (admin + moderator) ----------
adminRouter.get('/demandes', async (_req, res, next) => {
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 });
} catch (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) ----------
adminRouter.get('/users', adminOnly, async (req, res, next) => {
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),
email CITEXT NOT NULL CHECK (char_length(email) <= 254),
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()
);
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 (
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 { UsersTab } from '../components/admin/AdminUsers.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,
// site content, staff account management) is admin-only, enforced both
// here (sidebar) and server-side (routes).
const TABS = [
{ 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: 'demandes', icon: '◌', label: 'Demandes', title: "Demandes d’adhésion", 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: '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' }}>
{tab === 'dashboard' && <Dashboard />}
{tab === 'membres' && <MembersTab />}
{tab === 'demandes' && <RequestsTab />}
{tab === 'rencontres' && <RencontresTab />}
{tab === 'passees' && <PastEventsTab />}
{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; }
/* 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 */
.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); }
@@ -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-content-header { padding: 16px 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) {