From e1a54fa86b776ab6d79eb38a0ebbc881995fafc5 Mon Sep 17 00:00:00 2001 From: Olivier Date: Thu, 18 Jun 2026 23:11:14 +0200 Subject: [PATCH] Fix --- backend/src/routes/notifications.js | 4 +- frontend/src/pages/Communication.jsx | 406 ++++++++++++++++++++++----- frontend/src/styles.css | 2 + 3 files changed, 348 insertions(+), 64 deletions(-) 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 ( +
+ + {open && createPortal( +
+ {Object.entries(TYPE_META).map(([k, m]) => ( +
{ onChange(k); setOpen(false); }} + style={{ + display: 'flex', alignItems: 'center', gap: 10, + padding: '8px 12px', cursor: 'pointer', fontSize: 13, + color: k === value ? m.color : 'var(--text)', + background: k === value ? m.bg : 'transparent', + fontWeight: k === value ? 600 : 400, + }} + onMouseEnter={e => { if (k !== value) e.currentTarget.style.background = 'var(--surface-2)'; }} + onMouseLeave={e => { if (k !== value) e.currentTarget.style.background = 'transparent'; }} + > + + + + {m.label} + {k === value && ( + + + + )} +
+ ))} +
, + document.body + )} +
+ ); +} + function TypeSelector({ value, onChange }) { 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]); }} > @@ -1016,7 +1271,7 @@ export default function Communication() {
)} {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 &&
{stripPreview(n.body)}
} +
{n.body ? stripPreview(n.body) : ' '}
{timeAgo(n.created_at)}
@@ -1209,7 +1466,7 @@ export default function Communication() {
{msg.author_name} - {msg.is_admin && ( + {!!msg.is_admin && ( Support )} @@ -1404,58 +1661,7 @@ export default function Communication() { )} {/* Bloc broadcast admin (onglet notifications) */} - {tab === 'notifications' && isAdmin && showBroadcastForm && ( -
-
Envoyer une notification
-
-
- - -
-
- - -
-
- - setBcTitle(e.target.value)} - placeholder="Titre de la notification" - required - /> -
-
- -