diff --git a/backend/src/routes/notifications.js b/backend/src/routes/notifications.js
index 162355c..fc0ea98 100644
--- a/backend/src/routes/notifications.js
+++ b/backend/src/routes/notifications.js
@@ -46,7 +46,9 @@ router.post('/broadcast', requireAdmin, (req, res) => {
);
let targets;
- if (user_id) {
+ if (user_id === 'admins') {
+ targets = db.prepare("SELECT id FROM users WHERE role = 'admin'").all();
+ } else if (user_id) {
const u = db.prepare('SELECT id FROM users WHERE id = ?').get(user_id);
if (!u) return res.status(404).json({ error: 'Utilisateur introuvable' });
targets = [u];
diff --git a/frontend/src/pages/Communication.jsx b/frontend/src/pages/Communication.jsx
index 75f5cb5..87a0879 100644
--- a/frontend/src/pages/Communication.jsx
+++ b/frontend/src/pages/Communication.jsx
@@ -524,6 +524,94 @@ function RichTextEditor({ value, onChange, onImgExpand, placeholder, minHeight =
}
// ── Sélecteur de type de notification ─────────────────────────────────────
+// ── TypeDropdown — liste déroulante custom avec icônes ─────────────────────
+function TypeDropdown({ value, onChange }) {
+ const [open, setOpen] = useState(false);
+ const [pos, setPos] = useState({ top: 0, left: 0, width: 0 });
+ const triggerRef = useRef(null);
+ const dropRef = useRef(null);
+ const meta = TYPE_META[value] ?? TYPE_META.info;
+
+ useEffect(() => {
+ if (!open) return;
+ const h = e => {
+ if (triggerRef.current?.contains(e.target) || dropRef.current?.contains(e.target)) return;
+ setOpen(false);
+ };
+ document.addEventListener('mousedown', h);
+ return () => document.removeEventListener('mousedown', h);
+ }, [open]);
+
+ const openDrop = () => {
+ if (triggerRef.current) {
+ const r = triggerRef.current.getBoundingClientRect();
+ setPos({ top: r.bottom + 4, left: r.left, width: r.width });
+ }
+ setOpen(o => !o);
+ };
+
+ return (
+
@@ -549,6 +637,176 @@ function TypeSelector({ value, onChange }) {
);
}
+// ── RecipientDropdown — destinataire notification broadcast ─────────────────
+function RecipientDropdown({ value, onChange, users }) {
+ const [open, setOpen] = useState(false);
+ const [search, setSearch] = useState('');
+ const [pos, setPos] = useState({ top: 0, left: 0, width: 0 });
+ const triggerRef = useRef(null);
+ const dropRef = useRef(null);
+ const searchRef = useRef(null);
+
+ useEffect(() => {
+ if (!open) return;
+ setTimeout(() => searchRef.current?.focus(), 30);
+ const h = e => {
+ if (triggerRef.current?.contains(e.target) || dropRef.current?.contains(e.target)) return;
+ setOpen(false);
+ };
+ document.addEventListener('mousedown', h);
+ return () => document.removeEventListener('mousedown', h);
+ }, [open]);
+
+ const openDrop = () => {
+ if (triggerRef.current) {
+ const r = triggerRef.current.getBoundingClientRect();
+ setPos({ top: r.bottom + 4, left: r.left, width: r.width });
+ }
+ setSearch('');
+ setOpen(o => !o);
+ };
+
+ const select = (v) => { onChange(v); setOpen(false); };
+
+ // Label affiché dans le bouton
+ const SPECIAL = [
+ { value: '', label: 'Tous les utilisateurs', icon: (
+
+ )},
+ { value: 'admins', label: 'Tous les administrateurs', icon: (
+
+ )},
+ ];
+
+ const specialMatch = SPECIAL.find(s => s.value === value);
+ const userMatch = users.find(u => String(u.id) === String(value));
+ const triggerLabel = specialMatch?.label ?? userMatch?.display_name ?? userMatch?.email ?? 'Tous les utilisateurs';
+ const triggerIcon = specialMatch?.icon ?? null;
+
+ // Filtrer users par recherche
+ const q = search.toLowerCase();
+ const filteredUsers = q
+ ? users.filter(u => (u.display_name ?? '').toLowerCase().includes(q) || u.email.toLowerCase().includes(q))
+ : users;
+
+ return (
+
+
+ {open && createPortal(
+
+ {/* Champ recherche */}
+
+ setSearch(e.target.value)}
+ placeholder="Rechercher..."
+ style={{
+ width: '100%', padding: '5px 8px', borderRadius: 6, fontSize: 12,
+ border: '1px solid var(--border)', background: 'var(--surface-2)',
+ color: 'var(--text)', outline: 'none', boxSizing: 'border-box',
+ }}
+ />
+
+ {/* Options spéciales (pas filtrées) */}
+
+ {SPECIAL.map(s => (
+
select(s.value)}
+ style={{
+ display: 'flex', alignItems: 'center', gap: 8,
+ padding: '8px 12px', cursor: 'pointer', fontSize: 13,
+ background: value === s.value ? 'var(--primary-light, rgba(99,102,241,0.1))' : 'transparent',
+ color: value === s.value ? 'var(--primary)' : 'var(--text)',
+ fontWeight: value === s.value ? 600 : 400,
+ }}
+ onMouseEnter={e => { if (value !== s.value) e.currentTarget.style.background = 'var(--surface-2)'; }}
+ onMouseLeave={e => { if (value !== s.value) e.currentTarget.style.background = 'transparent'; }}
+ >
+
{s.icon}
+
{s.label}
+ {value === s.value && (
+
+ )}
+
+ ))}
+ {/* Séparateur */}
+ {filteredUsers.length > 0 && (
+
+ Utilisateurs individuels
+
+ )}
+ {filteredUsers.map(u => {
+ const sel = String(u.id) === String(value);
+ const name = u.display_name ?? u.email;
+ return (
+
select(String(u.id))}
+ style={{
+ display: 'flex', alignItems: 'center', gap: 8,
+ padding: '7px 12px', cursor: 'pointer', fontSize: 13,
+ background: sel ? 'var(--primary-light, rgba(99,102,241,0.1))' : 'transparent',
+ color: sel ? 'var(--primary)' : 'var(--text)',
+ fontWeight: sel ? 600 : 400,
+ }}
+ onMouseEnter={e => { if (!sel) e.currentTarget.style.background = 'var(--surface-2)'; }}
+ onMouseLeave={e => { if (!sel) e.currentTarget.style.background = 'transparent'; }}
+ >
+
+ {name.charAt(0).toUpperCase()}
+
+
+
{name}
+ {u.display_name &&
{u.email}
}
+
+ {sel && (
+
+ )}
+
+ );
+ })}
+ {filteredUsers.length === 0 && q && (
+
Aucun résultat
+ )}
+
+
,
+ document.body
+ )}
+
+ );
+}
+
// ══════════════════════════════════════════════════════════════════════════
// COMPOSANT PRINCIPAL
// ══════════════════════════════════════════════════════════════════════════
@@ -578,7 +836,7 @@ export default function Communication() {
const [tickets, setTickets] = useState([]);
const [ticketSearch, setTicketSearch] = useState('');
const [ticketFilter, setTicketFilter] = useState(new Set(['open', 'pending'])); // multi-sélection
- const [notifFilter, setNotifFilter] = useState(new Set(['read', 'unread'])); // multi-sélection
+ const [notifFilter, setNotifFilter] = useState(new Set(['unread'])); // multi-sélection
const [selectedTicket, setSelectedTicket] = useState(null);
const [thread, setThread] = useState(null); // { ticket, messages }
const [notifs, setNotifs] = useState([]);
@@ -630,8 +888,6 @@ export default function Communication() {
setNotifs(list);
setNotifsTotal(data.total ?? 0);
setNotifPage(0);
- // Auto-sélectionner la première notif si aucune n'est sélectionnée
- setSelectedNotif(prev => prev ?? (list.length > 0 ? list[0] : null));
} catch { /* silencieux */ }
}, []);
@@ -775,7 +1031,7 @@ export default function Communication() {
setBcResult(null);
try {
const payload = { type: bcType, title: bcTitle.trim(), body: bcBody.trim() || undefined };
- if (bcUserId) payload.user_id = Number(bcUserId);
+ if (bcUserId) payload.user_id = bcUserId === 'admins' ? 'admins' : Number(bcUserId);
const data = await api.post('/notifications/broadcast', payload);
setBcResult({ ok: true, msg: `Envoyé à ${data.sent} utilisateur${data.sent > 1 ? 's' : ''}` });
setBcTitle('');
@@ -944,7 +1200,6 @@ export default function Communication() {
className={`comm-nav-item${tab === 'notifications' ? ' active' : ''}`}
onClick={() => {
setTab('notifications');
- if (!selectedNotif && notifs.length > 0) setSelectedNotif(notifs[0]);
}}
>
)}
{tickets
- .filter(t => ticketFilter.size === 0 || ticketFilter.has(t.status))
+ .filter(t => ticketFilter.size > 0 && ticketFilter.has(t.status))
.filter(t => {
if (!ticketSearch.trim()) return true;
const q = ticketSearch.toLowerCase();
@@ -1056,9 +1311,11 @@ export default function Communication() {
{tab === 'notifications' && (() => {
const NOTIF_PER_PAGE = 10;
- const filteredNotifs = notifFilter.size === 2 || notifFilter.size === 0
- ? notifs
- : notifFilter.has('unread') ? notifs.filter(n => !n.read) : notifs.filter(n => n.read);
+ const filteredNotifs = notifFilter.size === 0
+ ? []
+ : notifFilter.size === 2
+ ? notifs
+ : notifFilter.has('unread') ? notifs.filter(n => !n.read) : notifs.filter(n => n.read);
const totalNotifs = filteredNotifs.length;
const lastPage = Math.max(0, Math.ceil(totalNotifs / NOTIF_PER_PAGE) - 1);
const pagedNotifs = filteredNotifs.slice(notifPage * NOTIF_PER_PAGE, (notifPage + 1) * NOTIF_PER_PAGE);
@@ -1127,7 +1384,7 @@ export default function Communication() {
{(TYPE_META[n.type] ?? TYPE_META.info).label}
- {n.body &&