Ajoute le suivi des demandes d'adhésion
This commit is contained in:
1 parent
b4a3019098
commit
c49c45e65f
6 files changed
+291
-7
No files matched your search
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 />}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user