import { useCallback, useEffect, useRef, useState } from 'react'; import { useSearchParams, useNavigate } from 'react-router-dom'; import { api } from '../api.js'; import { useAuth } from '../context/AuthContext.jsx'; import NotifTypeAvatar, { TYPE_META } from '../components/NotifTypeAvatar.jsx'; import { fmtDate } from '../utils/format.js'; // ── Helpers ──────────────────────────────────────────────────────────────── function timeAgo(dateStr) { if (!dateStr) return ''; 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' }); } function fmtSize(bytes) { if (bytes < 1024) return `${bytes} o`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} Ko`; return `${(bytes / 1024 / 1024).toFixed(1)} Mo`; } function initials(name) { if (!name) return '?'; // Si c'est un email, prendre les 2 premières lettres avant le @ if (name.includes('@')) return name.split('@')[0].slice(0, 2).toUpperCase(); return name.split(' ').filter(Boolean).map(w => w[0]).join('').toUpperCase().slice(0, 2); } // ── Avatar lettres ───────────────────────────────────────────────────────── function UserAvatar({ name, isAdmin, size = 38 }) { const bg = isAdmin ? 'var(--primary)' : '#64748b'; return (
{initials(name)}
); } // ── Badge statut ticket ──────────────────────────────────────────────────── function StatusBadge({ status }) { const styles = { open: { bg: '#dcfce7', color: '#16a34a', label: 'Ouvert' }, resolved: { bg: '#f1f5f9', color: '#64748b', label: 'Résolu' }, }; const s = styles[status] ?? styles.open; return ( {s.label} ); } // ── Icône fichier selon MIME ─────────────────────────────────────────────── function FileIcon({ mime }) { if (mime?.startsWith('image/')) return '🖼'; if (mime === 'application/pdf') return '📄'; if (mime?.includes('zip') || mime?.includes('compressed')) return '🗜'; if (mime?.includes('spreadsheet') || mime?.includes('excel')) return '📊'; return '📎'; } // ── Chip fichier avec aperçu image ──────────────────────────────────────── function FileChip({ file, onRemove }) { const isImage = file.type.startsWith('image/'); const [url, setUrl] = useState(null); useEffect(() => { if (!isImage) return; const objUrl = URL.createObjectURL(file); setUrl(objUrl); return () => URL.revokeObjectURL(objUrl); }, [file, isImage]); return ( {isImage && url ? {file.name} : } {file.name} ); } // ── Gestion du coller image ─────────────────────────────────────────────── function onPasteImage(setter) { return (e) => { const items = Array.from(e.clipboardData?.items ?? []); const images = items .filter(item => item.type.startsWith('image/')) .map(item => item.getAsFile()) .filter(Boolean) .map((f, i) => { const ext = f.type.split('/')[1]?.replace('jpeg', 'jpg') || 'png'; return new File([f], `image-collée-${Date.now()}${i ? '-' + i : ''}.${ext}`, { type: f.type }); }); if (images.length) { setter(prev => [...prev, ...images]); } }; } // ── Helper nom téléchargement ───────────────────────────────────────────── function buildDownloadName(ticketNumber, msgDate, originalName) { const d = msgDate ? new Date(msgDate.endsWith('Z') ? msgDate : msgDate + 'Z') : new Date(); const yyyy = d.getFullYear(); const mm = String(d.getMonth() + 1).padStart(2, '0'); const dd = String(d.getDate()).padStart(2, '0'); const hh = String(d.getHours()).padStart(2, '0'); const mn = String(d.getMinutes()).padStart(2, '0'); const ext = (originalName.match(/\.[^.]+$/) ?? [''])[0]; const base = originalName.replace(/\.[^.]+$/, '').replace(/[^\w\-]/g, '_').slice(0, 40); return `${ticketNumber}_${yyyy}-${mm}-${dd}_${hh}h${mn}_${base}${ext}`; } // ── Image jointe dans le thread ─────────────────────────────────────────── function AttachmentImage({ filename, originalName, size, onExpand, ticketNumber, msgDate }) { const [url, setUrl] = useState(null); useEffect(() => { let objUrl; api.blob(`/tickets/attachments/${filename}`) .then(blob => { objUrl = URL.createObjectURL(blob); setUrl(objUrl); }).catch(() => {}); return () => { if (objUrl) URL.revokeObjectURL(objUrl); }; }, [filename]); const handleDownload = () => { if (!url) return; const a = document.createElement('a'); a.href = url; a.download = buildDownloadName(ticketNumber || 'TK', msgDate, originalName); a.click(); }; return (
{url ? {originalName} :
🖼
} {url && (
)}
{originalName} · {fmtSize(size)}
); } // ── Constantes tags tickets ─────────────────────────────────────────────── const TICKET_TYPES = [ { value: 'bug_bloquant', label: 'Bug bloquant' }, { value: 'bug_non_bloquant', label: 'Bug non bloquant' }, { value: 'amelioration', label: 'Amélioration' }, { value: 'question', label: 'Question' }, ]; const TICKET_PAGES = [ { value: 'tableau_de_bord', label: 'Tableau de bord' }, { value: 'plateformes', label: 'Plateformes' }, { value: 'investissements', label: 'Investissements' }, { value: 'depots_retraits', label: 'Dépôts / Retraits' }, { value: 'fiscalite', label: 'Fiscalité' }, { value: 'mon_compte', label: 'Mon compte' }, { value: 'parametres', label: 'Paramètres' }, { value: 'autres', label: 'Autres' }, ]; const TYPE_COLORS = { bug_bloquant: { background: '#fee2e2', color: '#b91c1c' }, bug_non_bloquant: { background: '#ffedd5', color: '#c2410c' }, amelioration: { background: '#dbeafe', color: '#1d4ed8' }, question: { background: '#d1fae5', color: '#065f46' }, }; // ── TagSelect ───────────────────────────────────────────────────────────── function TagSelect({ label, options, value, onChange, multi = false }) { const [open, setOpen] = useState(false); const ref = useRef(null); useEffect(() => { const h = e => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); }; document.addEventListener('mousedown', h); return () => document.removeEventListener('mousedown', h); }, []); const isSelected = v => multi ? (value || []).includes(v) : value === v; const toggle = v => { if (multi) { const arr = value || []; onChange(arr.includes(v) ? arr.filter(x => x !== v) : [...arr, v]); } else { onChange(value === v ? null : v); setOpen(false); } }; const selectedCount = multi ? (value?.length || 0) : (value ? 1 : 0); const headerLabel = multi ? (selectedCount === 0 ? label : `${selectedCount} sélectionné${selectedCount > 1 ? 's' : ''}`) : (value ? options.find(o => o.value === value)?.label ?? label : label); return (
{open && (
{options.map(opt => ( ))}
)}
); } // ── TicketChips ─────────────────────────────────────────────────────────── function TicketChips({ ticketType, ticketPages }) { const type = TICKET_TYPES.find(t => t.value === ticketType); const pages = ticketPages ? (Array.isArray(ticketPages) ? ticketPages : JSON.parse(ticketPages)) : []; if (!type && pages.length === 0) return null; return (
{type && ( {type.label} )} {pages.map(p => { const page = TICKET_PAGES.find(x => x.value === p); return page ? {page.label} : null; })}
); } // ── MessageBody — rendu HTML avec overlay CSS sur images inline ──────────── const ICON_EXPAND_SVG = ``; const ICON_DOWNLOAD_SVG = ``; function MessageBody({ html, onExpand, ticketNumber, msgDate }) { const ref = useRef(null); useEffect(() => { const el = ref.current; if (!el) return; el.querySelectorAll('.msg-inline-img:not([data-hovered])').forEach(img => { img.setAttribute('data-hovered', '1'); const wrap = document.createElement('span'); wrap.className = 'msg-img-wrap'; img.parentNode.insertBefore(wrap, img); wrap.appendChild(img); const overlay = document.createElement('span'); overlay.className = 'msg-img-hover-overlay'; overlay.innerHTML = `` + ``; wrap.appendChild(overlay); }); }, [html]); const handleClick = useCallback((e) => { const btn = e.target.closest('[data-img-action]'); if (btn) { const img = btn.closest('.msg-img-wrap')?.querySelector('img'); if (!img) return; if (btn.dataset.imgAction === 'expand') { onExpand(img.src, img.alt || 'image'); } else { const a = document.createElement('a'); a.href = img.src; a.download = buildDownloadName(ticketNumber || 'TK', msgDate, img.alt || 'image.png'); a.click(); } return; } if (e.target.tagName === 'IMG') onExpand(e.target.src, e.target.alt || 'image'); }, [onExpand]); return (
); } // ── Sanitisation HTML (affichage messages) ──────────────────────────────── const ALLOWED_TAGS = new Set(['b','i','u','s','strong','em','ul','ol','li','a','br','p','div','span','img']); function sanitizeHTML(html) { if (!html) return ''; const doc = new DOMParser().parseFromString(html, 'text/html'); function clean(node) { for (const child of [...node.childNodes]) { if (child.nodeType === 3) continue; if (child.nodeType === 1) { const tag = child.tagName.toLowerCase(); if (!ALLOWED_TAGS.has(tag)) { child.replaceWith(...child.childNodes); continue; } if (tag === 'img') { const src = child.getAttribute('src') ?? ''; if (!src.startsWith('data:image/')) { child.remove(); continue; } // Garde uniquement src et alt for (const attr of [...child.attributes]) { if (attr.name !== 'src' && attr.name !== 'alt') child.removeAttribute(attr.name); } child.setAttribute('class', 'msg-inline-img'); continue; // pas d'enfants } for (const attr of [...child.attributes]) { if (tag === 'a' && attr.name === 'href') continue; child.removeAttribute(attr.name); } if (tag === 'a') { child.setAttribute('target','_blank'); child.setAttribute('rel','noreferrer'); } clean(child); } else { child.remove(); } } } clean(doc.body); return doc.body.innerHTML; } // ── Nettoie l'HTML de l'éditeur avant envoi (retire les wrappers rte-img-wrap) ── function stripPreview(html) { if (!html) return ''; return html.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 120); } function stripEditorHTML(html) { if (!html) return ''; const div = document.createElement('div'); div.innerHTML = html; for (const wrap of [...div.querySelectorAll('.rte-img-wrap')]) { const img = wrap.querySelector('img'); if (img) wrap.replaceWith(img); else wrap.remove(); } return div.innerHTML; } // ── Éditeur de texte riche (contenteditable) ─────────────────────────────── function RichTextEditor({ value, onChange, onImgExpand, placeholder, minHeight = 100 }) { const ref = useRef(null); useEffect(() => { if (ref.current && (value === '' || value === null || value === undefined)) { ref.current.innerHTML = ''; } }, [value]); const exec = (cmd, val = null) => { // Ne redonner le focus que si l'éditeur ne l'a pas déjà // (focus() détruirait la sélection active) if (document.activeElement !== ref.current) ref.current?.focus(); document.execCommand(cmd, false, val); onChange(ref.current?.innerHTML ?? ''); }; const insertLink = () => { const url = window.prompt('URL du lien :'); if (url?.trim()) exec('createLink', url.trim()); }; // Coller une image → insertion inline au curseur avec overlay Agrandir/Supprimer const handlePaste = (e) => { const items = Array.from(e.clipboardData?.items ?? []); const imageItems = items.filter(item => item.type.startsWith('image/')); if (!imageItems.length) return; e.preventDefault(); imageItems.forEach(item => { const file = item.getAsFile(); if (!file) return; const reader = new FileReader(); reader.onload = ev => { const dataUrl = ev.target.result; const ICON_EXPAND = ``; const ICON_DEL = ``; const html = `` + `image collée` + `` + `` + `` + `​`; if (document.activeElement !== ref.current) ref.current?.focus(); document.execCommand('insertHTML', false, html); onChange(ref.current?.innerHTML ?? ''); }; reader.readAsDataURL(file); }); }; // Délégation de clics sur les boutons Agrandir/Supprimer dans l'éditeur const handleEditorClick = (e) => { const btn = e.target.closest('[data-rte-action]'); if (!btn) return; e.preventDefault(); const action = btn.dataset.rteAction; const wrap = btn.closest('.rte-img-wrap'); if (action === 'remove' && wrap) { wrap.remove(); onChange(ref.current?.innerHTML ?? ''); } else if (action === 'expand' && wrap) { const img = wrap.querySelector('img'); if (img) onImgExpand?.(img.src, 'image collée'); } }; return (
onChange(e.currentTarget.innerHTML)} onPaste={handlePaste} onClick={handleEditorClick} data-placeholder={placeholder} style={{ minHeight }} />
); } // ── Sélecteur de type de notification ───────────────────────────────────── function TypeSelector({ value, onChange }) { return (
{Object.entries(TYPE_META).map(([k, m]) => ( ))}
); } // ══════════════════════════════════════════════════════════════════════════ // COMPOSANT PRINCIPAL // ══════════════════════════════════════════════════════════════════════════ export default function Communication() { const { user, isAdmin } = useAuth(); const [searchParams, setSearchParams] = useSearchParams(); const navigate = useNavigate(); const [tab, setTab] = useState('support'); // 'support' | 'notifications' const [showBroadcastForm, setShowBroadcastForm] = useState(false); const [lightbox, setLightbox] = useState(null); // { url, name } const [editingMsg, setEditingMsg] = useState(null); // { id, body } const [editSaving, setEditSaving] = useState(false); // Tick toutes les 10s pour rafraîchir les boutons "Modifier" (fenêtre 5 min) const [now, setNow] = useState(Date.now()); useEffect(() => { const id = setInterval(() => setNow(Date.now()), 10_000); return () => clearInterval(id); }, []); const [tickets, setTickets] = useState([]); const [ticketSearch, setTicketSearch] = useState(''); const [ticketFilter, setTicketFilter] = useState('all'); // 'all'|'open'|'resolved' const [notifFilter, setNotifFilter] = useState('all'); // 'all'|'unread' const [selectedTicket, setSelectedTicket] = useState(null); const [thread, setThread] = useState(null); // { ticket, messages } const [notifs, setNotifs] = useState([]); const [notifPage, setNotifPage] = useState(0); const [notifsTotal, setNotifsTotal] = useState(0); const [selectedNotif, setSelectedNotif] = useState(null); const [loading, setLoading] = useState(false); // ── Nouveau ticket ─────────────────────────────────────────────────── const [showCompose, setShowCompose] = useState(false); const [composeSubject, setComposeSubject] = useState(''); const [composeBody, setComposeBody] = useState(''); const [composeFiles, setComposeFiles] = useState([]); const [composeType, setComposeType] = useState(null); const [composePages, setComposePages] = useState([]); const [composeSending, setComposeSending] = useState(''); const fileInputRef = useRef(null); // ── Répondre ───────────────────────────────────────────────────────── const [replyBody, setReplyBody] = useState(''); const [replyFiles, setReplyFiles] = useState([]); const replyFileRef = useRef(null); const [replySending, setReplySending] = useState(false); // ── Broadcast (admin) ──────────────────────────────────────────────── const [bcType, setBcType] = useState('announcement'); const [bcTitle, setBcTitle] = useState(''); const [bcBody, setBcBody] = useState(''); const [bcUserId, setBcUserId] = useState(''); const [bcSending, setBcSending] = useState(false); const [bcResult, setBcResult] = useState(null); const [users, setUsers] = useState([]); // ── Charger tickets ────────────────────────────────────────────────── const fetchTickets = useCallback(async () => { setLoading(true); try { const data = await api.get('/tickets'); setTickets(data.tickets ?? []); } catch { /* silencieux */ } setLoading(false); }, []); // ── Charger notifications ──────────────────────────────────────────── const fetchNotifs = useCallback(async () => { try { const data = await api.get('/notifications', { limit: 50 }); const list = data.notifications ?? []; 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 */ } }, []); // ── Charger thread ─────────────────────────────────────────────────── const fetchThread = useCallback(async (id) => { try { const data = await api.get(`/tickets/${id}`); setThread(data); } catch { /* silencieux */ } }, []); // ── Charger users (admin) ──────────────────────────────────────────── const fetchUsers = useCallback(async () => { if (!isAdmin) return; try { const data = await api.get('/admin/users'); setUsers(data.users ?? data ?? []); } catch { /* silencieux */ } }, [isAdmin]); useEffect(() => { fetchTickets(); fetchNotifs(); if (isAdmin) fetchUsers(); }, [fetchTickets, fetchNotifs, fetchUsers]); // ── Restaurer ticket depuis URL ────────────────────────────────────── useEffect(() => { const ticketId = searchParams.get('ticket'); if (ticketId) { setTab('support'); setSelectedTicket(Number(ticketId)); fetchThread(ticketId); // Nettoyer le param URL sans recharger setSearchParams({}, { replace: true }); } }, [searchParams]); // eslint-disable-line // ── Ouvrir un ticket ───────────────────────────────────────────────── const openTicket = (id) => { setSelectedTicket(id); setSelectedNotif(null); fetchThread(id); setSearchParams({ ticket: id }); setReplyBody(''); setReplyFiles([]); }; // ── Ouvrir une notification ────────────────────────────────────────── const openNotif = async (n) => { setSelectedNotif(n); setSelectedTicket(null); setShowBroadcastForm(false); if (!n.read) { try { await api.patch(`/notifications/${n.id}/read`); setNotifs(prev => prev.map(x => x.id === n.id ? { ...x, read: 1 } : x)); window.dispatchEvent(new CustomEvent('notif:refresh')); } catch { /* silencieux */ } } }; // ── Créer ticket ───────────────────────────────────────────────────── const submitTicket = async (e) => { e.preventDefault(); if (!composeSubject.trim() || !composeBody.replace(/<[^>]*>/g,'').trim()) return; setComposeSending('sending'); try { const fd = new FormData(); fd.append('subject', composeSubject.trim()); fd.append('body', stripEditorHTML(composeBody).trim()); if (composeType) fd.append('ticket_type', composeType); if (composePages.length) fd.append('ticket_pages', JSON.stringify(composePages)); for (const f of composeFiles) fd.append('attachments', f); const data = await api.postForm('/tickets', fd); setShowCompose(false); setComposeSubject(''); setComposeBody(''); setComposeType(null); setComposePages([]); setComposeFiles([]); await fetchTickets(); if (data.ticketId) openTicket(data.ticketId); } catch { /* silencieux */ } setComposeSending(''); }; // ── Répondre ───────────────────────────────────────────────────────── const submitReply = async (e) => { e.preventDefault(); if (!replyBody.trim() || !thread) return; setReplySending(true); try { const fd = new FormData(); fd.append('body', stripEditorHTML(replyBody).trim()); for (const f of replyFiles) fd.append('attachments', f); await api.postForm(`/tickets/${thread.ticket.id}/messages`, fd); setReplyBody(''); setReplyFiles([]); await fetchThread(thread.ticket.id); await fetchTickets(); } catch { /* silencieux */ } setReplySending(false); }; // ── Résoudre / rouvrir ─────────────────────────────────────────────── const toggleStatus = async () => { if (!thread) return; const newStatus = thread.ticket.status === 'open' ? 'resolved' : 'open'; try { await api.patch(`/tickets/${thread.ticket.id}/status`, { status: newStatus }); await fetchThread(thread.ticket.id); await fetchTickets(); } catch { /* silencieux */ } }; // ── Broadcast ──────────────────────────────────────────────────────── const submitBroadcast = async (e) => { e.preventDefault(); if (!bcTitle.trim()) return; setBcSending(true); setBcResult(null); try { const payload = { type: bcType, title: bcTitle.trim(), body: bcBody.trim() || undefined }; if (bcUserId) payload.user_id = Number(bcUserId); const data = await api.post('/notifications/broadcast', payload); setBcResult({ ok: true, msg: `Envoyé à ${data.sent} utilisateur${data.sent > 1 ? 's' : ''}` }); setBcTitle(''); setBcBody(''); setBcUserId(''); window.dispatchEvent(new CustomEvent('notif:refresh')); await fetchNotifs(); // rafraîchir la liste locale immédiatement setShowBroadcastForm(false); } catch { setBcResult({ ok: false, msg: 'Erreur lors de l\'envoi' }); } setBcSending(false); }; // ── Marquer toutes notifs lues ─────────────────────────────────────── const markAllRead = async () => { try { await api.patch('/notifications/read-all'); setNotifs(prev => prev.map(n => ({ ...n, read: 1 }))); window.dispatchEvent(new CustomEvent('notif:refresh')); } catch { /* silencieux */ } }; // ── Édition message (fenêtre 5 min) ──────────────────────────────────── const EDIT_WINDOW_MS = 5 * 60 * 1000; const canEditMsg = (msg) => msg.user_id === user?.id && (now - new Date(msg.created_at + 'Z').getTime()) < EDIT_WINDOW_MS; const fmtRemaining = (msg) => { const ms = EDIT_WINDOW_MS - (now - new Date(msg.created_at + 'Z').getTime()); if (ms <= 0) return null; const mins = Math.ceil(ms / 60_000); return mins <= 1 ? '< 1 min' : `${mins} min`; }; const saveEdit = async (msg) => { if (!editingMsg?.body?.replace(/<[^>]*>/g, '').trim()) return; setEditSaving(true); try { await api.put(`/tickets/${msg.ticket_id}/messages/${msg.id}`, { body: editingMsg.body }); // Mettre à jour localement sans recharger tout le thread setThread(prev => ({ ...prev, messages: prev.messages.map(m => m.id === msg.id ? { ...m, body: editingMsg.body, updated_at: new Date().toISOString() } : m ), })); setEditingMsg(null); } catch (e) { alert(e.message || 'Erreur lors de la sauvegarde'); } setEditSaving(false); }; const unreadCount = notifs.filter(n => !n.read).length; // ──────────────────────────────────────────────────────────────────── // RENDER // ──────────────────────────────────────────────────────────────────── return ( <>
{/* ── Topbar 3 colonnes ── */}
{/* Colonne 1 — Titre page */}
Communication
{/* Colonne 2 — Dossier + filtres */}
{tab === 'support' ? 'Support' : 'Notifications'}
{tab === 'support' && ( <> {[['all','Tous'],['open','Ouverts'],['resolved','Résolus']].map(([v,l]) => ( ))} )} {tab === 'notifications' && ( <> {[['all','Tous'],['unread','Non lus']].map(([v,l]) => ( ))} {unreadCount > 0 && ( )} )}
{/* Colonne 3 — Toolbar contextuelle */}
{tab === 'support' && thread && ( <> {thread.ticket.ticket_number} — {thread.ticket.subject}
{isAdmin && ( )}
)} {tab === 'notifications' && selectedNotif && (
{!selectedNotif.read && ( )} {selectedNotif.link && ( )}
)}
{/* ── Corps 3 panneaux ── */}
{/* ── Sidebar gauche ── */} {/* ── Liste centrale ── */}
{tab === 'support' && ( <>
setTicketSearch(e.target.value)} />
{loading &&
Chargement…
} {!loading && tickets.length === 0 && (
💬
Aucun ticket
Créez votre premier ticket de support
)} {tickets .filter(t => { if (!ticketSearch.trim()) return true; const q = ticketSearch.toLowerCase(); return ( t.subject?.toLowerCase().includes(q) || t.ticket_number?.toLowerCase().includes(q) || t.user_name?.toLowerCase().includes(q) ); }) .map(t => (
openTicket(t.id)} >
{t.user_name ?? 'Moi'}
{t.ticket_number} — {t.subject}
{t.last_body && (
{stripPreview(t.last_body)}
)}
{t.message_count} message{t.message_count > 1 ? 's' : ''} {timeAgo(t.last_message_at ?? t.created_at)}
))}
)} {tab === 'notifications' && (() => { const NOTIF_PER_PAGE = 10; const filteredNotifs = notifFilter === 'unread' ? notifs.filter(n => !n.read) : notifs; 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); const start = totalNotifs === 0 ? 0 : notifPage * NOTIF_PER_PAGE + 1; const end = Math.min((notifPage + 1) * NOTIF_PER_PAGE, totalNotifs); return ( <>
Notifications
{notifs.length === 0 && (
🔔
Aucune notification
)} {pagedNotifs.map(n => (
openNotif(n)} >
{n.title} {(TYPE_META[n.type] ?? TYPE_META.info).label}
{n.body &&
{stripPreview(n.body)}
}
{timeAgo(n.created_at)}
{!n.read && }
))}
{totalNotifs > NOTIF_PER_PAGE && (
{start}–{end} sur {totalNotifs}
)} ); })()}
{/* ── Panneau détail droite ── */}
{/* Détail ticket */} {tab === 'support' && thread && ( <>
{thread.ticket.ticket_number} — {thread.ticket.subject}
Ouvert par {thread.ticket.user_name} · {fmtDate(thread.ticket.created_at)}
{thread.messages.map(msg => (
{msg.author_name} {msg.is_admin && ( Support )} {msg.updated_at && ( modifié )} {timeAgo(msg.created_at)} {canEditMsg(msg) && editingMsg?.id !== msg.id && ( )}
{editingMsg?.id === msg.id ? (
setEditingMsg(e => ({ ...e, body }))} placeholder="Modifiez votre message…" minHeight={80} />
⏱ {fmtRemaining(msg) ?? 'Délai expiré'}
) : ( setLightbox({ url: src, name })} ticketNumber={thread.ticket.ticket_number} msgDate={msg.created_at} /> )} {msg.attachments?.length > 0 && (() => { const images = msg.attachments.filter(a => a.mime_type?.startsWith('image/')); const files = msg.attachments.filter(a => !a.mime_type?.startsWith('image/')); return ( <> {images.length > 0 && (
{images.map(a => ( setLightbox({ url, name })} ticketNumber={thread.ticket.ticket_number} msgDate={msg.created_at} /> ))}
)} {files.length > 0 && ( )} ); })()}
))}
{/* Zone réponse */} {(thread.ticket.status === 'open' || isAdmin) && (
setLightbox({ url, name })} placeholder="Votre réponse… (Ctrl+V pour coller une image)" minHeight={90} /> {replyFiles.length > 0 && (
{replyFiles.map((f, i) => ( setReplyFiles(prev => prev.filter((_, j) => j !== i))} /> ))}
)}
setReplyFiles(prev => [...prev, ...Array.from(e.target.files)])} />
)} {thread.ticket.status === 'resolved' && !isAdmin && (
Ce ticket est résolu. Créez un nouveau ticket si besoin.
)} )} {/* Détail notification */} {tab === 'notifications' && selectedNotif && (
{selectedNotif.title}
{timeAgo(selectedNotif.created_at)} · {(TYPE_META[selectedNotif.type] ?? TYPE_META.info).label}
{selectedNotif.body && (
{selectedNotif.body}
)} {selectedNotif.link && (
)}
)} {/* Bloc broadcast admin (onglet notifications) */} {tab === 'notifications' && isAdmin && showBroadcastForm && (
Envoyer une notification
setBcTitle(e.target.value)} placeholder="Titre de la notification" required />