Centre de notification

This commit is contained in:
2026-06-18 20:26:23 +02:00
parent bfc97442d9
commit 5bbbbfa956
13 changed files with 3685 additions and 0 deletions
+4
View File
@@ -23,6 +23,8 @@ import AdminFiscalite from './pages/AdminFiscalite.jsx';
import Aide from './pages/Aide.jsx';
import PlatformeProfile from './pages/PlatformeProfile.jsx';
import Plateformes from './pages/Plateformes.jsx';
import Notifications from './pages/Notifications.jsx';
import Communication from './pages/Communication.jsx';
function Protected({ children }) {
const { token, loading } = useAuth();
@@ -73,6 +75,8 @@ export default function App() {
<Route path="admin/plateformes" element={<AdminOnly><AdminPlateformes /></AdminOnly>} />
<Route path="admin/fiscalite" element={<AdminOnly><AdminFiscalite /></AdminOnly>} />
<Route path="aide" element={<Aide />} />
<Route path="notifications" element={<Notifications />} />
<Route path="communication" element={<Communication />} />
<Route path="referentiel/:id" element={<PlatformeProfile />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Route>
+2
View File
@@ -58,6 +58,8 @@ export const api = {
fetch(BASE + path, { method: 'DELETE', headers: authHeaders() }).then(handle),
upload: (path, formData) =>
fetch(BASE + path, { method: 'POST', body: formData, headers: authHeaders() }).then(handle),
postForm: (path, formData) =>
fetch(BASE + path, { method: 'POST', body: formData, headers: authHeaders() }).then(handle),
blob: (path) =>
fetch(BASE + path, { headers: authHeaders() }).then(async res => {
if (!res.ok) { const t = await res.text(); throw new Error(t || res.statusText); }
+3
View File
@@ -5,6 +5,7 @@ import { useInvestisseur } from '../context/InvestisseurContext.jsx';
import { useUi } from '../context/UiContext.jsx';
import Logo from './Logo.jsx';
import UserMenu from './UserMenu.jsx';
import NotificationBell from './NotificationBell.jsx';
/* ── Icônes nav ─────────────────────────────────────────────── */
const ICONS_BASE = '/api/icons-files/';
@@ -406,6 +407,8 @@ export default function Layout() {
Net
</button>
</div>
<NotificationBell />
</div>
</div>
@@ -0,0 +1,97 @@
// Icônes : paths Lucide (Radio, FileText, Info, Users, Check, AlertTriangle, ShieldAlert, Megaphone)
export const TYPE_META = {
system: { label: 'Système', color: '#6b7280', bg: '#f3f4f6' },
ticket_reply: { label: 'Ticket', color: '#3b82f6', bg: '#eff6ff' },
info: { label: 'Info', color: '#0ea5e9', bg: '#f0f9ff' },
team: { label: 'Équipe', color: '#8b5cf6', bg: '#f5f3ff' },
success: { label: 'Succès', color: '#10b981', bg: '#ecfdf5' },
warning: { label: 'Avertissement', color: '#f59e0b', bg: '#fffbeb' },
security: { label: 'Sécurité', color: '#ef4444', bg: '#fef2f2' },
announcement: { label: 'Annonce', color: '#ec4899', bg: '#fdf2f8' },
};
const ICONS = {
// Lucide: Radio
system: (s) => (
<svg width={s} height={s} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="3"/>
<path d="M19.07 4.93a10 10 0 0 1 0 14.14M4.93 4.93a10 10 0 0 0 0 14.14"/>
</svg>
),
// Lucide: FileText
ticket_reply: (s) => (
<svg width={s} height={s} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<line x1="16" y1="13" x2="8" y2="13"/>
<line x1="16" y1="17" x2="8" y2="17"/>
</svg>
),
// Lucide: Info
info: (s) => (
<svg width={s} height={s} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10"/>
<line x1="12" y1="8" x2="12" y2="12"/>
<line x1="12" y1="16" x2="12.01" y2="16"/>
</svg>
),
// Lucide: Users
team: (s) => (
<svg width={s} height={s} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
</svg>
),
// Lucide: Check
success: (s) => (
<svg width={s} height={s} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12"/>
</svg>
),
// Lucide: AlertTriangle
warning: (s) => (
<svg width={s} height={s} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/>
<line x1="12" y1="9" x2="12" y2="13"/>
<line x1="12" y1="17" x2="12.01" y2="17"/>
</svg>
),
// Lucide: ShieldAlert
security: (s) => (
<svg width={s} height={s} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
<line x1="12" y1="8" x2="12" y2="12"/>
<line x1="12" y1="16" x2="12.01" y2="16"/>
</svg>
),
// Lucide: Megaphone
announcement: (s) => (
<svg width={s} height={s} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 11l19-9-9 19-2-8-8-2z"/>
</svg>
),
};
const FALLBACK = (s) => (
<svg width={s} height={s} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="10"/>
</svg>
);
export default function NotifTypeAvatar({ type, size = 42 }) {
const meta = TYPE_META[type] ?? TYPE_META.info;
const iconSize = Math.round(size * 0.43);
const renderIcon = ICONS[type] ?? FALLBACK;
return (
<div style={{
width: size, height: size, borderRadius: '50%', flexShrink: 0,
background: meta.bg, color: meta.color,
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
{renderIcon(iconSize)}
</div>
);
}
@@ -0,0 +1,177 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { useNavigate } from 'react-router-dom';
import { api } from '../api.js';
import NotifTypeAvatar from './NotifTypeAvatar.jsx';
const POLL_INTERVAL = 30_000; // 30s
function timeAgo(dateStr) {
const diff = (Date.now() - new Date(dateStr + 'Z').getTime()) / 1000;
if (diff < 60) return 'À l\'instant';
if (diff < 3600) return `Il y a ${Math.floor(diff / 60)} min`;
if (diff < 86400) return `Il y a ${Math.floor(diff / 3600)} h`;
if (diff < 86400 * 7) return `Il y a ${Math.floor(diff / 86400)} j`;
return new Date(dateStr + 'Z').toLocaleDateString('fr-FR', { day: '2-digit', month: 'short' });
}
export default function NotificationBell() {
const [unread, setUnread] = useState(0);
const [open, setOpen] = useState(false);
const [notifs, setNotifs] = useState([]);
const [loading, setLoading] = useState(false);
const [dropPos, setDropPos] = useState({ top: 0, right: 0 });
const bellRef = useRef(null);
const dropRef = useRef(null);
const navigate = useNavigate();
// ── Polling unread count ────────────────────────────────────────────────
const fetchCount = useCallback(async () => {
try {
const data = await api.get('/notifications/count');
setUnread(data.unread ?? 0);
} catch { /* silencieux */ }
}, []);
useEffect(() => {
fetchCount();
const id = setInterval(fetchCount, POLL_INTERVAL);
// Rafraîchissement immédiat sur demande (ex: après un seed)
window.addEventListener('notif:refresh', fetchCount);
return () => {
clearInterval(id);
window.removeEventListener('notif:refresh', fetchCount);
};
}, [fetchCount]);
// ── Charger les notifs dans le dropdown ──────────────────────────────────
const fetchNotifs = useCallback(async () => {
setLoading(true);
try {
const data = await api.get('/notifications', { limit: 10 });
setNotifs(data.notifications ?? []);
} catch { /* silencieux */ }
setLoading(false);
}, []);
const handleOpen = () => {
if (open) { setOpen(false); return; }
const rect = bellRef.current?.getBoundingClientRect();
if (rect) {
setDropPos({
top: rect.bottom + 8,
right: window.innerWidth - rect.right,
});
}
setOpen(true);
fetchNotifs();
};
// ── Fermer au clic extérieur ─────────────────────────────────────────────
useEffect(() => {
if (!open) return;
const handler = (e) => {
if (
!bellRef.current?.contains(e.target) &&
!dropRef.current?.contains(e.target)
) setOpen(false);
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [open]);
// ── Actions ──────────────────────────────────────────────────────────────
const markRead = async (id) => {
try {
await api.patch(`/notifications/${id}/read`);
setNotifs(prev => prev.map(n => n.id === id ? { ...n, read: 1 } : n));
setUnread(prev => Math.max(0, prev - 1));
} catch { /* silencieux */ }
};
const markAll = async () => {
try {
await api.patch('/notifications/read-all');
setNotifs(prev => prev.map(n => ({ ...n, read: 1 })));
setUnread(0);
} catch { /* silencieux */ }
};
const handleNotifClick = (n) => {
if (!n.read) markRead(n.id);
if (n.link) { setOpen(false); navigate(n.link); }
};
// ── Dropdown ─────────────────────────────────────────────────────────────
const dropdown = open && createPortal(
<div
ref={dropRef}
className="notif-dropdown"
style={{ top: dropPos.top, right: dropPos.right }}
>
<div className="notif-dropdown-header">
<span className="notif-dropdown-title">Notifications</span>
{unread > 0 && (
<button className="notif-mark-all-btn" onClick={markAll}>
Tout marquer lu
</button>
)}
</div>
<div className="notif-dropdown-list">
{loading && (
<div className="notif-empty">Chargement</div>
)}
{!loading && notifs.length === 0 && (
<div className="notif-empty">Aucune notification</div>
)}
{!loading && notifs.map(n => (
<div
key={n.id}
className={`notif-item${!n.read ? ' notif-item-unread' : ''}`}
onClick={() => handleNotifClick(n)}
>
<NotifTypeAvatar type={n.type} size={36} />
<div className="notif-item-body">
<div className="notif-item-title">{n.title}</div>
{n.body && <div className="notif-item-text">{n.body}</div>}
<div className="notif-item-time">{timeAgo(n.created_at)}</div>
</div>
{!n.read && <span className="notif-unread-dot" />}
</div>
))}
</div>
<div className="notif-dropdown-footer">
<button
className="notif-view-all-btn"
onClick={() => { setOpen(false); navigate('/notifications'); }}
>
Voir toutes les notifications
</button>
</div>
</div>,
document.body
);
return (
<>
<button
ref={bellRef}
className={`notif-bell-btn${unread > 0 ? ' has-unread' : ''}`}
onClick={handleOpen}
title="Notifications"
aria-label={`Notifications${unread > 0 ? ` (${unread} non lues)` : ''}`}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9" />
<path d="M13.73 21a2 2 0 0 1-3.46 0" />
</svg>
{unread > 0 && (
<span className="notif-badge">{unread > 99 ? '99+' : unread}</span>
)}
</button>
{dropdown}
</>
);
}
+6
View File
@@ -22,6 +22,9 @@ function IconSettings() {
function IconAide() {
return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>;
}
function IconComm() {
return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>;
}
function IconChevronRight() {
return <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M9 18l6-6-6-6"/></svg>;
}
@@ -259,6 +262,9 @@ export default function UserMenu() {
<button className="user-menu-item" role="menuitem" onClick={() => go('/settings')}>
<IconSettings /> Paramètres
</button>
<button className="user-menu-item" role="menuitem" onClick={() => go('/communication')}>
<IconComm /> Support &amp; Notifications
</button>
<button className="user-menu-item" role="menuitem" onClick={() => go('/aide')}>
<IconAide /> Aide
</button>
File diff suppressed because it is too large Load Diff
+291
View File
@@ -0,0 +1,291 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { api } from '../api.js';
import { useAuth } from '../context/AuthContext.jsx';
import NotifTypeAvatar, { TYPE_META } from '../components/NotifTypeAvatar.jsx';
const PAGE_SIZE = 10;
// Dropdown générique (Status / Type)
function FilterDropdown({ label, value, options, onChange }) {
const [open, setOpen] = useState(false);
const ref = useRef(null);
const current = options.find(o => o.value === value);
useEffect(() => {
if (!open) return;
const h = (e) => { if (!ref.current?.contains(e.target)) setOpen(false); };
document.addEventListener('mousedown', h);
return () => document.removeEventListener('mousedown', h);
}, [open]);
return (
<div ref={ref} style={{ position: 'relative' }}>
<button
className="notif-filter-btn"
onClick={() => setOpen(o => !o)}
>
{label}{current && current.value !== options[0].value ? ` : ${current.label}` : ''}
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" style={{ marginLeft: 4 }}>
<polyline points="6 9 12 15 18 9"/>
</svg>
</button>
{open && (
<div className="notif-filter-menu">
{options.map(o => (
<button
key={o.value}
className={`notif-filter-option${value === o.value ? ' active' : ''}`}
onClick={() => { onChange(o.value); setOpen(false); }}
>
{value === o.value && (
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" style={{ marginRight: 6, flexShrink: 0 }}>
<polyline points="20 6 9 17 4 12"/>
</svg>
)}
{value !== o.value && <span style={{ width: 19, flexShrink: 0 }} />}
{o.label}
</button>
))}
</div>
)}
</div>
);
}
function timeAgo(dateStr) {
const diff = (Date.now() - new Date(dateStr + 'Z').getTime()) / 1000;
if (diff < 60) return 'À l\'instant';
if (diff < 3600) return `Il y a ${Math.floor(diff / 60)} min`;
if (diff < 86400) return `Il y a ${Math.floor(diff / 3600)} h`;
if (diff < 86400 * 7) return `Il y a ${Math.floor(diff / 86400)} j`;
return new Date(dateStr + 'Z').toLocaleDateString('fr-FR', { day: '2-digit', month: 'short', year: 'numeric' });
}
const STATUS_OPTIONS = [
{ value: 'all', label: 'Tous' },
{ value: 'unread', label: 'Non lus' },
{ value: 'read', label: 'Lus' },
];
const TYPE_OPTIONS = [
{ value: 'all', label: 'Tous les types' },
...Object.entries(TYPE_META).map(([v, m]) => ({ value: v, label: m.label })),
];
export default function Notifications() {
const { isAdmin } = useAuth();
const [notifs, setNotifs] = useState([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [page, setPage] = useState(0);
const [search, setSearch] = useState('');
const [filterStatus, setFilterStatus] = useState('all');
const [filterType, setFilterType] = useState('all');
const [seeding, setSeeding] = useState(false);
const [seedCount, setSeedCount] = useState(3);
const fetchNotifs = useCallback(async (p = 0, status = filterStatus, type = filterType) => {
setLoading(true);
try {
const params = { limit: PAGE_SIZE, offset: p * PAGE_SIZE };
if (status === 'unread') params.unread_only = 'true';
if (type !== 'all') params.type = type;
const data = await api.get('/notifications', params);
setNotifs(data.notifications ?? []);
setTotal(data.total ?? 0);
} catch { /* silencieux */ }
setLoading(false);
}, [filterStatus, filterType]);
useEffect(() => {
fetchNotifs(page, filterStatus, filterType);
}, [page, filterStatus, filterType]); // eslint-disable-line
const handleStatus = (v) => { setFilterStatus(v); setPage(0); };
const handleType = (v) => { setFilterType(v); setPage(0); };
const markRead = async (id) => {
try {
await api.patch(`/notifications/${id}/read`);
setNotifs(prev => prev.map(n => n.id === id ? { ...n, read: 1 } : n));
} catch { /* silencieux */ }
};
const markAll = async () => {
try {
await api.patch('/notifications/read-all');
setNotifs(prev => prev.map(n => ({ ...n, read: 1 })));
} catch { /* silencieux */ }
};
const deleteNotif = async (id) => {
try {
await api.del(`/notifications/${id}`);
setNotifs(prev => prev.filter(n => n.id !== id));
setTotal(prev => Math.max(0, prev - 1));
} catch { /* silencieux */ }
};
const seedNotifs = async () => {
setSeeding(true);
try {
await api.post('/notifications/seed', { count: seedCount });
await fetchNotifs(0, filterStatus, filterType);
setPage(0);
// Forcer le rafraîchissement du compteur dans la cloche
window.dispatchEvent(new CustomEvent('notif:refresh'));
} catch { /* silencieux */ }
setSeeding(false);
};
// Filtrage local par recherche (sur titre + body)
const visible = search.trim()
? notifs.filter(n =>
n.title.toLowerCase().includes(search.toLowerCase()) ||
(n.body ?? '').toLowerCase().includes(search.toLowerCase())
)
: notifs;
const unreadTotal = notifs.filter(n => !n.read).length;
const totalPages = Math.ceil(total / PAGE_SIZE);
const from = page * PAGE_SIZE + 1;
const to = Math.min((page + 1) * PAGE_SIZE, total);
return (
<>
{/* topbar vide pour respecter le layout (pas de contenu ici) */}
<div className="topbar" style={{ display: 'none' }} aria-hidden />
{/* ── Wrapper centré (.main ajoute padding: 0 24px) ── */}
<div className="notif-center-wrap">
{/* ── Bloc principal notifications ── */}
<div className="card notif-block">
{/* En-tête du bloc */}
<div className="notif-block-header">
<h2 className="notif-page-title">Notifications</h2>
<button
className="notif-markall-btn"
onClick={markAll}
disabled={unreadTotal === 0}
>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><polyline points="20 6 9 17 4 12"/></svg>
Tout marquer lu
</button>
</div>
{/* Barre recherche + filtres */}
<div className="notif-toolbar" style={{ borderTop: '1px solid var(--border)' }}>
<div className="notif-search-wrap">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="notif-search-icon">
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
</svg>
<input
className="notif-search-input"
placeholder="Rechercher des notifications…"
value={search}
onChange={e => setSearch(e.target.value)}
/>
</div>
<div className="notif-filters">
<FilterDropdown label="Statut" value={filterStatus} options={STATUS_OPTIONS} onChange={handleStatus} />
<FilterDropdown label="Type" value={filterType} options={TYPE_OPTIONS} onChange={handleType} />
</div>
</div>
{/* Séparateur */}
<div style={{ borderTop: '1px solid var(--border)' }} />
{/* Liste */}
{loading && (
<div style={{ padding: 40, textAlign: 'center', color: 'var(--text-muted)', fontSize: 14 }}>
Chargement
</div>
)}
{!loading && visible.length === 0 && (
<div style={{ padding: 56, textAlign: 'center', color: 'var(--text-muted)' }}>
<div style={{ fontSize: 36, marginBottom: 10 }}>🔔</div>
<div style={{ fontWeight: 600, marginBottom: 4 }}>Aucune notification</div>
<div style={{ fontSize: 13 }}>Vous êtes à jour !</div>
</div>
)}
{!loading && visible.map((n, i) => (
<div
key={n.id}
className={`notif-row${!n.read ? ' notif-row-unread' : ''}`}
style={{ borderTop: i === 0 ? 'none' : '1px solid var(--border)' }}
>
<NotifTypeAvatar type={n.type} />
<div className="notif-row-body">
<div className="notif-row-title">{n.title}</div>
{n.body && <div className="notif-row-text">{n.body}</div>}
</div>
<span className="notif-type-badge" style={{
background: (TYPE_META[n.type] ?? TYPE_META.info).bg,
color: (TYPE_META[n.type] ?? TYPE_META.info).color,
}}>
{(TYPE_META[n.type] ?? TYPE_META.info).label}
</span>
<span className="notif-row-time">{timeAgo(n.created_at)}</span>
<div className="notif-row-actions">
{!n.read && (
<button className="btn-icon-sm" title="Marquer comme lu" onClick={() => markRead(n.id)}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><polyline points="20 6 9 17 4 12"/></svg>
</button>
)}
<button className="btn-icon-sm" title="Supprimer" onClick={() => deleteNotif(n.id)}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14H6L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4h6v2"/></svg>
</button>
{!n.read && <span className="notif-unread-dot" />}
</div>
</div>
))}
{/* Pagination */}
<div className="notif-pagination">
<span className="notif-pagination-info">
{total === 0 ? 'Aucune notification' : `Affichage de ${from} à ${to} sur ${total} notification${total > 1 ? 's' : ''}`}
</span>
<div style={{ display: 'flex', gap: 8 }}>
<button className="notif-page-btn" disabled={page === 0} onClick={() => setPage(p => p - 1)}>
Précédent
</button>
<button className="notif-page-btn" disabled={page >= totalPages - 1} onClick={() => setPage(p => p + 1)}>
Suivant
</button>
</div>
</div>
</div>{/* fin .notif-block */}
{/* ── Bloc Simulation (admin uniquement) ── */}
{isAdmin && (
<div className="card notif-block" style={{ marginTop: 16 }}>
<div className="notif-block-header">
<h3 style={{ margin: 0, fontSize: 15, fontWeight: 600, color: 'var(--text)' }}>
Simulation
</h3>
</div>
<div className="notif-admin-bar" style={{ borderTop: '1px solid var(--border)' }}>
<span style={{ color: 'var(--text-muted)', fontSize: 13 }}>Générer des notifications de test :</span>
<select
value={seedCount}
onChange={e => setSeedCount(Number(e.target.value))}
className="notif-seed-select"
>
{[1, 2, 3, 4, 5, 6].map(n => (
<option key={n} value={n}>{n} notif{n > 1 ? 's' : ''}</option>
))}
</select>
<button className="btn btn-sm" onClick={seedNotifs} disabled={seeding} style={{ whiteSpace: 'nowrap' }}>
{seeding ? 'Création…' : '+ Créer'}
</button>
</div>
</div>
)}
</div>{/* fin .notif-center-wrap */}
</>
);
}
File diff suppressed because it is too large Load Diff