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 (
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 */}
{/* 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 && (
)}
{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) && (
)}
{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 && (
)}
{/* Placeholder vide */}
{tab === 'support' && !thread && (
)}
{tab === 'notifications' && !selectedNotif && !showBroadcastForm && (
Sélectionnez une notification
)}
{/* end comm-wrap */}
{/* end comm-page */}
{/* ── Lightbox image ── */}
{lightbox && (
setLightbox(null)}>

e.stopPropagation()}
/>
)}
{/* ── Modale nouveau ticket ── */}
{showCompose && (
setShowCompose(false)}>
e.stopPropagation()}>
Nouveau ticket de support
)}
>
);
}