import { useCallback, useEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; 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' }, pending: { bg: '#fef9c3', color: '#a16207', label: 'En attente' }, }; 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 [dropPos, setDropPos] = useState({ top: 0, left: 0, width: 0 }); const triggerRef = useRef(null); const dropRef = useRef(null); useEffect(() => { if (!open) return; const h = e => { if ( triggerRef.current && !triggerRef.current.contains(e.target) && dropRef.current && !dropRef.current.contains(e.target) ) setOpen(false); }; document.addEventListener('mousedown', h); return () => document.removeEventListener('mousedown', h); }, [open]); const openDropdown = () => { if (!open && triggerRef.current) { const r = triggerRef.current.getBoundingClientRect(); setDropPos({ top: r.bottom + 4, left: r.left, width: r.width }); } setOpen(o => !o); }; 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 && createPortal(
{options.map(opt => { const sel = isSelected(opt.value); return (
toggle(opt.value)} > {/* Bulle radio/checkbox custom */} {sel && ( {multi ? : } )} {opt.label}
); })}
, document.body )}
); } // ── 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 ───────────────────────────────────── // ── 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 (
{Object.entries(TYPE_META).map(([k, m]) => ( ))}
); } // ── 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 // ══════════════════════════════════════════════════════════════════════════ export default function Communication() { const { user, isAdmin } = useAuth(); const [searchParams, setSearchParams] = useSearchParams(); const navigate = useNavigate(); const [notifMenuPos, setNotifMenuPos] = useState(null); // { x, y } const [supportMenuPos, setSupportMenuPos] = useState(null); // { x, y } const [showAssignModal, setShowAssignModal] = useState(false); const [showCategModal, setShowCategModal] = useState(false); const [categType, setCategType] = useState(null); const [categPages, setCategPages] = useState([]); const [assignSearch, setAssignSearch] = useState(''); 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(new Set(['open', 'pending'])); // 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([]); 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); } 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 ?? []).filter(u => u.role === 'admin')); } 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 */ } }; // ── Assigner un ticket ─────────────────────────────────────────────── const assignTicket = async (adminId) => { if (!thread) return; try { await api.patch(`/tickets/${thread.ticket.id}/assign`, { assigned_to: adminId }); await fetchThread(thread.ticket.id); } catch { /* silencieux */ } }; // ── Mettre en attente ──────────────────────────────────────────────── const setTicketPending = async () => { if (!thread) return; try { await api.patch(`/tickets/${thread.ticket.id}/status`, { status: 'pending' }); 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 = 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(''); 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); }; // ── Supprimer toutes les notifications ──────────────────────────────────── const deleteAllNotifs = async () => { try { await Promise.all(notifs.map(n => api.del(`/notifications/${n.id}`))); setNotifs([]); setSelectedNotif(null); window.dispatchEvent(new CustomEvent('notif:refresh')); } catch { /* silencieux */ } }; // ── Supprimer une notification ──────────────────────────────────────────── const deleteNotif = async (id) => { try { await api.del(`/notifications/${id}`); setNotifs(prev => prev.filter(n => n.id !== id)); setSelectedNotif(null); window.dispatchEvent(new CustomEvent('notif:refresh')); } catch { /* silencieux */ } }; // ── 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'}
{/* Colonne 3 — Toolbar contextuelle */}
{tab === 'support' && thread && ( <>
)} {tab === 'notifications' && selectedNotif && (
)}
{/* ── Corps 3 panneaux ── */}
{/* ── Sidebar gauche ── */} {/* ── Liste centrale ── */}
{tab === 'support' && ( <>
{[['open','Ouverts'],['pending','En attente'],['resolved','Résolus']].map(([v,l]) => { const active = ticketFilter.has(v); return ( ); })}
setTicketSearch(e.target.value)} />
{loading &&
Chargement…
} {!loading && tickets.length === 0 && (
💬
Aucun ticket
Créez votre premier ticket de support
)} {tickets .filter(t => ticketFilter.size > 0 && ticketFilter.has(t.status)) .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.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); const start = totalNotifs === 0 ? 0 : notifPage * NOTIF_PER_PAGE + 1; const end = Math.min((notifPage + 1) * NOTIF_PER_PAGE, totalNotifs); return ( <>
{[['unread','Non lues'],['read','Lues']].map(([v,l]) => { const active = notifFilter.has(v); return ( ); })}
{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 && ( <>
{/* Ligne 1 : type chip + titre + badge statut */}
{thread.ticket.ticket_type && (() => { const type = TICKET_TYPES.find(t => t.value === thread.ticket.ticket_type); return type ? ( {type.label} ) : null; })()} {thread.ticket.ticket_number} — {thread.ticket.subject}
{/* Ligne 2 : meta gauche + chips pages droite */} {(() => { const pages = thread.ticket.ticket_pages ? (Array.isArray(thread.ticket.ticket_pages) ? thread.ticket.ticket_pages : JSON.parse(thread.ticket.ticket_pages)) : []; return (
Ouvert par {thread.ticket.user_name} · {fmtDate(thread.ticket.created_at)} {thread.ticket.assigned_name && ( <> — assigné à {thread.ticket.assigned_name} )}
{pages.map(p => { const page = TICKET_PAGES.find(x => x.value === p); return page ? {page.label} : null; })}
); })()}
{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) */} {/* broadcast form moved to modal */} {/* KPIs support */} {tab === 'support' && !thread && (() => { const kpis = [ { label: 'Total Tickets', count: tickets.length, bg: '#f3f4f6', color: '#111827' }, { label: 'En attente', count: tickets.filter(t => t.status === 'pending').length, bg: '#fefce8', color: '#a16207' }, { label: 'Ouverts', count: tickets.filter(t => t.status === 'open').length, bg: '#ecfdf5', color: '#0d9488' }, { label: 'Clôturés', count: tickets.filter(t => t.status === 'resolved').length, bg: '#fff1f2', color: '#e11d48' }, ]; return (
{kpis.map(k => (
{k.count}
{k.label}
))}
Sélectionnez un ticket
); })()} {tab === 'notifications' && !selectedNotif && !showBroadcastForm && (
Sélectionnez une notification
)}
{/* end comm-wrap */}
{/* end comm-page */} {/* ── Modale Nouvelle notification (admin) ── */} {showBroadcastForm && isAdmin && (() => { const selectedMeta = TYPE_META[bcType] ?? TYPE_META.info; return (
setShowBroadcastForm(false)}>
e.stopPropagation()}>
Nouvelle notification
{/* Type — liste déroulante custom */}
Type
{/* Destinataire — dropdown searchable */}
Destinataire
{/* Titre */}
Titre *
setBcTitle(e.target.value)} placeholder="Titre de la notification" autoFocus required />
{/* Message */}
Message (optionnel)
setLightbox({ url, name })} placeholder="Corps du message… (Ctrl+V pour coller une image)" minHeight={120} />
{bcResult && (
{bcResult.msg}
)}
); })()} {/* ── Menu ⋮ notifications ── */} {notifMenuPos && ( <>
setNotifMenuPos(null)} />
{selectedNotif && !selectedNotif.read && ( )} {unreadCount > 0 && ( )}
)} {/* ── Menu ⋮ support ── */} {supportMenuPos && ( <>
setSupportMenuPos(null)} />
{thread && ( )} {thread && isAdmin && ( <> {thread.ticket.status !== 'pending' ? ( ) : ( )} )} {thread && ( <>
)}
)} {/* ── Modale Assigner à ── */} {showAssignModal && (
setShowAssignModal(false)}>
e.stopPropagation()}>
Assigner le ticket
setAssignSearch(e.target.value)} autoFocus style={{ width: '100%', marginBottom: 10 }} /> {/* Désassigner */} {thread?.ticket.assigned_to && ( )} {/* Liste admins */}
{users .filter(u => !assignSearch.trim() || (u.display_name ?? u.email ?? '').toLowerCase().includes(assignSearch.toLowerCase())) .map(u => { const isAssigned = thread?.ticket.assigned_to === u.id; return ( ); }) }
)} {/* ── Modale catégorisation ticket ── */} {showCategModal && thread && (
setShowCategModal(false)}>
e.stopPropagation()}>
Modifier la catégorisation
Type
Pages concernées
)} {/* ── Lightbox image ── */} {lightbox && (
setLightbox(null)}> {lightbox.name} e.stopPropagation()} />
)} {/* ── Modale nouveau ticket ── */} {showCompose && (
setShowCompose(false)}>
e.stopPropagation()}>
Nouveau ticket de support
setComposeSubject(e.target.value)} placeholder="Décrivez votre problème en quelques mots" required autoFocus />
setLightbox({ url, name })} placeholder="Décrivez votre problème en détail… (Ctrl+V pour coller une image)" minHeight={150} />
{composeFiles.length > 0 && (
{composeFiles.map((f, i) => ( setComposeFiles(prev => prev.filter((_, j) => j !== i))} /> ))}
)}
setComposeFiles(prev => [...prev, ...Array.from(e.target.files)])} />
)} ); }