Files
crowdlending-app/frontend/src/pages/Communication.jsx
T
2026-06-18 20:34:05 +02:00

1381 lines
65 KiB
React
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 (
<div style={{
width: size, height: size, borderRadius: '50%', flexShrink: 0,
background: bg, color: '#fff', fontSize: size * 0.36,
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontWeight: 700, letterSpacing: '-0.5px',
}}>
{initials(name)}
</div>
);
}
// ── 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 (
<span style={{
fontSize: 11, fontWeight: 600, padding: '2px 8px', borderRadius: 99,
background: s.bg, color: s.color,
}}>{s.label}</span>
);
}
// ── 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 (
<span className={`comm-file-chip${isImage ? ' has-preview' : ''}`}>
{isImage && url
? <img src={url} alt={file.name} className="comm-file-chip-img" />
: <span style={{ fontSize: 16 }}><FileIcon mime={file.type} /></span>
}
<span className="comm-attach-name">{file.name}</span>
<button type="button" className="comm-file-remove" onClick={onRemove}>×</button>
</span>
);
}
// ── 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 (
<div className="comm-attach-image">
{url
? <img src={url} alt={originalName} className="comm-attach-img" />
: <div className="comm-attach-img-skeleton">🖼</div>
}
{url && (
<div className="comm-attach-image-overlay">
<button className="comm-attach-overlay-btn" title="Agrandir" onClick={() => onExpand(url, originalName)}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/></svg>
</button>
<button className="comm-attach-overlay-btn" title="Télécharger" onClick={handleDownload}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
</button>
</div>
)}
<div className="comm-attach-img-name">{originalName} · {fmtSize(size)}</div>
</div>
);
}
// ── 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 (
<div className="tag-select" ref={ref}>
<button
type="button"
className={`tag-select-trigger${selectedCount > 0 ? ' has-value' : ''}${open ? ' open' : ''}`}
onClick={() => setOpen(o => !o)}
>
<span>{headerLabel}</span>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
{open ? <polyline points="18 15 12 9 6 15"/> : <polyline points="6 9 12 15 18 9"/>}
</svg>
</button>
{open && (
<div className="tag-select-dropdown">
{options.map(opt => (
<label key={opt.value} className={`tag-select-option${isSelected(opt.value) ? ' selected' : ''}`}>
<input
type={multi ? 'checkbox' : 'radio'}
checked={isSelected(opt.value)}
onChange={() => toggle(opt.value)}
/>
<span>{opt.label}</span>
{isSelected(opt.value) && (
<svg className="tag-select-check" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><polyline points="20 6 9 17 4 12"/></svg>
)}
</label>
))}
</div>
)}
</div>
);
}
// ── 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 (
<div className="ticket-chips">
{type && (
<span className="ticket-chip ticket-chip-type" style={TYPE_COLORS[ticketType] || {}}>
{type.label}
</span>
)}
{pages.map(p => {
const page = TICKET_PAGES.find(x => x.value === p);
return page ? <span key={p} className="ticket-chip ticket-chip-page">{page.label}</span> : null;
})}
</div>
);
}
// ── MessageBody — rendu HTML avec overlay CSS sur images inline ────────────
const ICON_EXPAND_SVG = `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/></svg>`;
const ICON_DOWNLOAD_SVG = `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></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 =
`<button type="button" class="comm-attach-overlay-btn" title="Agrandir" data-img-action="expand">${ICON_EXPAND_SVG}</button>` +
`<button type="button" class="comm-attach-overlay-btn" title="Télécharger" data-img-action="download">${ICON_DOWNLOAD_SVG}</button>`;
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 (
<div
ref={ref}
className="comm-message-body"
dangerouslySetInnerHTML={{ __html: sanitizeHTML(html) }}
onClick={handleClick}
/>
);
}
// ── 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 = `<svg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2'><polyline points='15 3 21 3 21 9'/><polyline points='9 21 3 21 3 15'/><line x1='21' y1='3' x2='14' y2='10'/><line x1='3' y1='21' x2='10' y2='14'/></svg>`;
const ICON_DEL = `<svg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2'><polyline points='3 6 5 6 21 6'/><path d='M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6'/><path d='M10 11v6'/><path d='M14 11v6'/><path d='M9 6V4h6v2'/></svg>`;
const html = `<span class="rte-img-wrap" contenteditable="false">`
+ `<img src="${dataUrl}" class="rte-inline-img" alt="image collée" />`
+ `<span class="rte-img-overlay">`
+ `<button type="button" class="rte-img-overlay-btn" data-rte-action="expand">${ICON_EXPAND} Agrandir</button>`
+ `<button type="button" class="rte-img-overlay-btn rte-img-del" data-rte-action="remove">${ICON_DEL} Supprimer</button>`
+ `</span></span>`;
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 (
<div className="rte-wrap">
<div className="rte-toolbar">
<button type="button" className="rte-btn" title="Gras (Ctrl+B)"
onMouseDown={e => { e.preventDefault(); exec('bold'); }}><b>B</b></button>
<button type="button" className="rte-btn" title="Italique (Ctrl+I)"
onMouseDown={e => { e.preventDefault(); exec('italic'); }}><i>I</i></button>
<button type="button" className="rte-btn" title="Souligné (Ctrl+U)"
onMouseDown={e => { e.preventDefault(); exec('underline'); }}><u>U</u></button>
<button type="button" className="rte-btn" title="Barré"
onMouseDown={e => { e.preventDefault(); exec('strikeThrough'); }}><s>S</s></button>
<div className="rte-sep" />
<button type="button" className="rte-btn" title="Lien"
onMouseDown={e => { e.preventDefault(); insertLink(); }}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
Lien
</button>
<div className="rte-sep" />
<button type="button" className="rte-btn" title="Liste à puces"
onMouseDown={e => { e.preventDefault(); exec('insertUnorderedList'); }}>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="9" y1="6" x2="21" y2="6"/><line x1="9" y1="12" x2="21" y2="12"/><line x1="9" y1="18" x2="21" y2="18"/><circle cx="3.5" cy="6" r="1"/><circle cx="3.5" cy="12" r="1"/><circle cx="3.5" cy="18" r="1"/></svg>
</button>
<button type="button" className="rte-btn" title="Liste numérotée"
onMouseDown={e => { e.preventDefault(); exec('insertOrderedList'); }}>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="10" y1="6" x2="21" y2="6"/><line x1="10" y1="12" x2="21" y2="12"/><line x1="10" y1="18" x2="21" y2="18"/><path d="M4 6h1v4"/><path d="M4 10h2"/><path d="M6 18H4c0-1 2-2 2-3s-1-2-2-2"/></svg>
</button>
</div>
<div
ref={ref}
className="rte-editor"
contentEditable
suppressContentEditableWarning
onInput={e => onChange(e.currentTarget.innerHTML)}
onPaste={handlePaste}
onClick={handleEditorClick}
data-placeholder={placeholder}
style={{ minHeight }}
/>
</div>
);
}
// ── Sélecteur de type de notification ─────────────────────────────────────
function TypeSelector({ value, onChange }) {
return (
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
{Object.entries(TYPE_META).map(([k, m]) => (
<button
key={k}
type="button"
onClick={() => onChange(k)}
style={{
display: 'flex', alignItems: 'center', gap: 6,
padding: '5px 10px', borderRadius: 8, cursor: 'pointer', fontSize: 12,
border: value === k ? `2px solid ${m.color}` : '2px solid var(--border)',
background: value === k ? m.bg : 'transparent',
color: value === k ? m.color : 'var(--text-muted)',
fontWeight: value === k ? 600 : 400,
}}
>
<NotifTypeAvatar type={k} size={22} />
{m.label}
</button>
))}
</div>
);
}
// ══════════════════════════════════════════════════════════════════════════
// 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 (
<>
<div className="topbar" style={{ display: 'none' }} aria-hidden />
<div className="comm-page">
{/* ── Topbar 3 colonnes ── */}
<div className="comm-topbar">
{/* Colonne 1 — Titre page */}
<div className="comm-topbar-1">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" style={{ flexShrink: 0 }}><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
<span>Communication</span>
</div>
{/* Colonne 2 — Dossier + filtres */}
<div className="comm-topbar-2">
<span className="comm-topbar-folder">{tab === 'support' ? 'Support' : 'Notifications'}</span>
<div className="comm-topbar-filters">
{tab === 'support' && (
<>
{[['all','Tous'],['open','Ouverts'],['resolved','Résolus']].map(([v,l]) => (
<button key={v} className={`comm-topbar-filter-btn${ticketFilter === v ? ' active' : ''}`} onClick={() => setTicketFilter(v)}>{l}</button>
))}
</>
)}
{tab === 'notifications' && (
<>
{[['all','Tous'],['unread','Non lus']].map(([v,l]) => (
<button key={v} className={`comm-topbar-filter-btn${notifFilter === v ? ' active' : ''}`} onClick={() => { setNotifFilter(v); setNotifPage(0); }}>{l}</button>
))}
{unreadCount > 0 && (
<button className="comm-topbar-action-btn" onClick={markAllRead}>Tout marquer lu</button>
)}
</>
)}
</div>
</div>
{/* Colonne 3 — Toolbar contextuelle */}
<div className="comm-topbar-3">
{tab === 'support' && thread && (
<>
<span className="comm-topbar-ticket-ref">{thread.ticket.ticket_number} {thread.ticket.subject}</span>
<div style={{ display: 'flex', gap: 6, marginLeft: 'auto', flexShrink: 0 }}>
{isAdmin && (
<button className="btn btn-sm" onClick={toggleStatus}>
{thread.ticket.status === 'open' ? 'Résoudre' : 'Rouvrir'}
</button>
)}
</div>
</>
)}
{tab === 'notifications' && selectedNotif && (
<div style={{ display: 'flex', gap: 6, marginLeft: 'auto' }}>
{!selectedNotif.read && (
<button className="btn btn-sm btn-ghost" onClick={async () => {
await api.patch(`/notifications/${selectedNotif.id}/read`);
setNotifs(prev => prev.map(n => n.id === selectedNotif.id ? { ...n, read: 1 } : n));
setSelectedNotif(prev => ({ ...prev, read: 1 }));
}}>Marquer lu</button>
)}
{selectedNotif.link && (
<button className="btn btn-sm btn-primary" onClick={() => navigate(selectedNotif.link)}>
Voir le détail
</button>
)}
</div>
)}
</div>
</div>
{/* ── Corps 3 panneaux ── */}
<div className="comm-wrap">
{/* ── Sidebar gauche ── */}
<aside className="comm-sidebar">
{tab === 'support' ? (
<button
className="comm-compose-btn"
onClick={() => setShowCompose(true)}
>
+ Nouveau ticket
</button>
) : isAdmin ? (
<button
className="comm-compose-btn"
onClick={() => { setShowBroadcastForm(true); setSelectedNotif(null); }}
>
+ Nouvelle notification
</button>
) : null}
<nav className="comm-nav">
<button
className={`comm-nav-item${tab === 'support' ? ' active' : ''}`}
onClick={() => setTab('support')}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
</svg>
Support
{tickets.filter(t => t.status === 'open').length > 0 && (
<span className="comm-nav-badge">
{tickets.filter(t => t.status === 'open').length}
</span>
)}
</button>
<button
className={`comm-nav-item${tab === 'notifications' ? ' active' : ''}`}
onClick={() => {
setTab('notifications');
if (!selectedNotif && notifs.length > 0) setSelectedNotif(notifs[0]);
}}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/>
<path d="M13.73 21a2 2 0 0 1-3.46 0"/>
</svg>
Notifications
{unreadCount > 0 && <span className="comm-nav-badge">{unreadCount}</span>}
</button>
</nav>
{/* Filtres statut tickets (sidebar) */}
{tab === 'support' && (
<div className="comm-sidebar-section">
<div className="comm-sidebar-label">Tickets ouverts : {tickets.filter(t => t.status === 'open').length}</div>
<div className="comm-sidebar-label">Tickets résolus : {tickets.filter(t => t.status === 'resolved').length}</div>
</div>
)}
</aside>
{/* ── Liste centrale ── */}
<div className="comm-list">
{tab === 'support' && (
<>
<div className="comm-list-header">
<input
className="comm-search"
placeholder="Rechercher un ticket…"
style={{ flex: 1 }}
value={ticketSearch}
onChange={e => setTicketSearch(e.target.value)}
/>
</div>
<div className="comm-list-scroll">
{loading && <div className="comm-empty">Chargement</div>}
{!loading && tickets.length === 0 && (
<div className="comm-empty">
<div style={{ fontSize: 32, marginBottom: 8 }}>💬</div>
<div style={{ fontWeight: 600 }}>Aucun ticket</div>
<div style={{ fontSize: 13, color: 'var(--text-muted)', marginTop: 4 }}>Créez votre premier ticket de support</div>
</div>
)}
{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 => (
<div
key={t.id}
className={`comm-list-row${selectedTicket === t.id ? ' selected' : ''}`}
onClick={() => openTicket(t.id)}
>
<UserAvatar name={t.user_name ?? t.user_email ?? user?.displayName ?? user?.email} size={38} />
<div className="comm-list-row-body">
<div className="comm-list-row-top">
<span className="comm-list-row-name">{t.user_name ?? 'Moi'}</span>
<StatusBadge status={t.status} />
</div>
<div className="comm-list-row-subject">{t.ticket_number} {t.subject}</div>
<TicketChips ticketType={t.ticket_type} ticketPages={t.ticket_pages} />
{t.last_body && (
<div className="comm-list-row-preview">{stripPreview(t.last_body)}</div>
)}
<div className="comm-list-row-meta">
<span>{t.message_count} message{t.message_count > 1 ? 's' : ''}</span>
<span>{timeAgo(t.last_message_at ?? t.created_at)}</span>
</div>
</div>
</div>
))}
</div>
</>
)}
{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 (
<>
<div className="comm-list-header">
<span style={{ fontWeight: 600, fontSize: 14 }}>Notifications</span>
</div>
<div className="comm-list-scroll">
{notifs.length === 0 && (
<div className="comm-empty">
<div style={{ fontSize: 32, marginBottom: 8 }}>🔔</div>
<div style={{ fontWeight: 600 }}>Aucune notification</div>
</div>
)}
{pagedNotifs.map(n => (
<div
key={n.id}
className={`comm-list-row${selectedNotif?.id === n.id ? ' selected' : ''}${!n.read ? ' unread' : ''}`}
onClick={() => openNotif(n)}
>
<NotifTypeAvatar type={n.type} size={38} />
<div className="comm-list-row-body">
<div className="comm-list-row-top">
<span className="comm-list-row-name">{n.title}</span>
<span style={{
fontSize: 11, padding: '2px 7px', borderRadius: 99, fontWeight: 600,
background: (TYPE_META[n.type] ?? TYPE_META.info).bg,
color: (TYPE_META[n.type] ?? TYPE_META.info).color,
}}>
{(TYPE_META[n.type] ?? TYPE_META.info).label}
</span>
</div>
{n.body && <div className="comm-list-row-preview">{stripPreview(n.body)}</div>}
<div className="comm-list-row-meta">
<span>{timeAgo(n.created_at)}</span>
</div>
</div>
{!n.read && <span className="comm-unread-dot" />}
</div>
))}
</div>
{totalNotifs > NOTIF_PER_PAGE && (
<div className="comm-list-pagination">
<span className="comm-list-pagination-info">
{start}{end} sur {totalNotifs}
</span>
<div className="comm-list-pagination-btns">
<button disabled={notifPage === 0} onClick={() => setNotifPage(p => p - 1)}>
Précédent
</button>
<button disabled={notifPage >= lastPage} onClick={() => setNotifPage(p => p + 1)}>
Suivant
</button>
</div>
</div>
)}
</>
);
})()}
</div>
{/* ── Panneau détail droite ── */}
<div className="comm-detail">
{/* Détail ticket */}
{tab === 'support' && thread && (
<>
<div className="comm-detail-header">
<div>
<div style={{ fontWeight: 700, fontSize: 15, marginBottom: 2 }}>
{thread.ticket.ticket_number} {thread.ticket.subject}
</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>
Ouvert par {thread.ticket.user_name} · {fmtDate(thread.ticket.created_at)}
</div>
<TicketChips ticketType={thread.ticket.ticket_type} ticketPages={thread.ticket.ticket_pages} />
</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<StatusBadge status={thread.ticket.status} />
</div>
</div>
<div className="comm-thread">
{thread.messages.map(msg => (
<div key={msg.id} className={`comm-message${msg.is_admin ? ' from-admin' : ''}`}>
<UserAvatar name={msg.author_name} isAdmin={!!msg.is_admin} size={34} />
<div className="comm-message-content">
<div className="comm-message-meta">
<span style={{ fontWeight: 600, fontSize: 13 }}>{msg.author_name}</span>
{msg.is_admin && (
<span style={{ fontSize: 11, color: 'var(--primary)', fontWeight: 600 }}>Support</span>
)}
<span style={{ fontSize: 12, color: 'var(--text-muted)', marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: 8 }}>
{msg.updated_at && (
<span style={{ fontSize: 11, color: 'var(--text-muted)', fontStyle: 'italic' }}>modifié</span>
)}
{timeAgo(msg.created_at)}
{canEditMsg(msg) && editingMsg?.id !== msg.id && (
<button
type="button"
className="comm-edit-btn"
title={`Modifier (encore ${fmtRemaining(msg)})`}
onClick={() => setEditingMsg({ id: msg.id, body: msg.body })}
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
</button>
)}
</span>
</div>
{editingMsg?.id === msg.id ? (
<div className="comm-edit-block">
<RichTextEditor
value={editingMsg.body}
onChange={body => setEditingMsg(e => ({ ...e, body }))}
placeholder="Modifiez votre message…"
minHeight={80}
/>
<div className="comm-edit-actions">
<span className="comm-edit-timer">
{fmtRemaining(msg) ?? 'Délai expiré'}
</span>
<button type="button" className="btn btn-ghost btn-sm"
onClick={() => setEditingMsg(null)}>Annuler</button>
<button type="button" className="btn btn-primary btn-sm"
disabled={editSaving || !editingMsg.body.replace(/<[^>]*>/g,'').trim() || !canEditMsg(msg)}
onClick={() => saveEdit(msg)}>
{editSaving ? 'Sauvegarde…' : 'Sauvegarder'}
</button>
</div>
</div>
) : (
<MessageBody
html={msg.body}
onExpand={(src, name) => 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 && (
<div className="comm-attach-images">
{images.map(a => (
<AttachmentImage
key={a.id}
filename={a.filename}
originalName={a.original_name}
size={a.size}
onExpand={(url, name) => setLightbox({ url, name })}
ticketNumber={thread.ticket.ticket_number}
msgDate={msg.created_at}
/>
))}
</div>
)}
{files.length > 0 && (
<div className="comm-attachments">
{files.map(a => (
<a
key={a.id}
href={`/api/tickets/attachments/${a.filename}`}
target="_blank"
rel="noreferrer"
className="comm-attach-chip"
>
<span style={{ fontSize: 16 }}><FileIcon mime={a.mime_type} /></span>
<span className="comm-attach-name">{a.original_name}</span>
<span className="comm-attach-size">{fmtSize(a.size)}</span>
</a>
))}
</div>
)}
</>
);
})()}
</div>
</div>
))}
</div>
{/* Zone réponse */}
{(thread.ticket.status === 'open' || isAdmin) && (
<form className="comm-reply-form" onSubmit={submitReply}>
<RichTextEditor
value={replyBody}
onChange={setReplyBody}
onImgExpand={(url, name) => setLightbox({ url, name })}
placeholder="Votre réponse… (Ctrl+V pour coller une image)"
minHeight={90}
/>
{replyFiles.length > 0 && (
<div className="comm-files-preview">
{replyFiles.map((f, i) => (
<FileChip key={i} file={f} onRemove={() => setReplyFiles(prev => prev.filter((_, j) => j !== i))} />
))}
</div>
)}
<div className="comm-reply-actions">
<button
type="button"
className="btn btn-ghost btn-sm"
onClick={() => replyFileRef.current?.click()}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
Joindre
</button>
<input
ref={replyFileRef}
type="file"
multiple
style={{ display: 'none' }}
onChange={e => setReplyFiles(prev => [...prev, ...Array.from(e.target.files)])}
/>
<button type="submit" className="btn btn-primary btn-sm" disabled={replySending || !replyBody.replace(/<[^>]*>/g,'').trim()}>
{replySending ? 'Envoi…' : 'Répondre'}
</button>
</div>
</form>
)}
{thread.ticket.status === 'resolved' && !isAdmin && (
<div style={{ padding: '12px 20px', color: 'var(--text-muted)', fontSize: 13, textAlign: 'center', borderTop: '1px solid var(--border)' }}>
Ce ticket est résolu. Créez un nouveau ticket si besoin.
</div>
)}
</>
)}
{/* Détail notification */}
{tab === 'notifications' && selectedNotif && (
<div className="comm-notif-detail">
<div className="comm-detail-header">
<NotifTypeAvatar type={selectedNotif.type} size={42} />
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 700, fontSize: 15 }}>{selectedNotif.title}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 2 }}>
{timeAgo(selectedNotif.created_at)} · {(TYPE_META[selectedNotif.type] ?? TYPE_META.info).label}
</div>
</div>
</div>
{selectedNotif.body && (
<div style={{ padding: '20px 24px', color: 'var(--text)', lineHeight: 1.6 }}>
{selectedNotif.body}
</div>
)}
{selectedNotif.link && (
<div style={{ padding: '0 24px 20px' }}>
<button className="btn btn-primary btn-sm" onClick={() => navigate(selectedNotif.link)}>
Voir le détail
</button>
</div>
)}
</div>
)}
{/* Bloc broadcast admin (onglet notifications) */}
{tab === 'notifications' && isAdmin && showBroadcastForm && (
<div className="comm-broadcast-panel">
<div className="comm-broadcast-title">Envoyer une notification</div>
<form onSubmit={submitBroadcast} className="comm-broadcast-form">
<div className="comm-form-group">
<label className="comm-label">Type</label>
<TypeSelector value={bcType} onChange={setBcType} />
</div>
<div className="comm-form-group">
<label className="comm-label">Destinataire</label>
<select
className="comm-select"
value={bcUserId}
onChange={e => setBcUserId(e.target.value)}
>
<option value="">Tous les utilisateurs</option>
{users.map(u => (
<option key={u.id} value={u.id}>{u.name} ({u.email})</option>
))}
</select>
</div>
<div className="comm-form-group">
<label className="comm-label">Titre *</label>
<input
className="comm-input"
value={bcTitle}
onChange={e => setBcTitle(e.target.value)}
placeholder="Titre de la notification"
required
/>
</div>
<div className="comm-form-group">
<label className="comm-label">Message (optionnel)</label>
<textarea
className="comm-reply-textarea"
value={bcBody}
onChange={e => setBcBody(e.target.value)}
placeholder="Corps du message…"
rows={3}
/>
</div>
{bcResult && (
<div style={{ fontSize: 13, color: bcResult.ok ? 'var(--success)' : 'var(--danger)', marginBottom: 8 }}>
{bcResult.msg}
</div>
)}
<button type="submit" className="btn btn-primary btn-sm" disabled={bcSending || !bcTitle.trim()}>
{bcSending ? 'Envoi…' : 'Envoyer'}
</button>
</form>
</div>
)}
{/* Placeholder vide */}
{tab === 'support' && !thread && (
<div className="comm-detail-empty">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="var(--border)" strokeWidth="1.5"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
<div style={{ marginTop: 12, color: 'var(--text-muted)', fontSize: 14 }}>Sélectionnez un ticket</div>
</div>
)}
{tab === 'notifications' && !selectedNotif && !showBroadcastForm && (
<div className="comm-detail-empty">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="var(--border)" strokeWidth="1.5"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>
<div style={{ marginTop: 12, color: 'var(--text-muted)', fontSize: 14 }}>Sélectionnez une notification</div>
</div>
)}
</div>
</div>{/* end comm-wrap */}
</div>{/* end comm-page */}
{/* ── Lightbox image ── */}
{lightbox && (
<div className="comm-lightbox" onClick={() => setLightbox(null)}>
<button className="comm-lightbox-close" onClick={() => setLightbox(null)}>×</button>
<button
className="comm-lightbox-download"
onClick={e => {
e.stopPropagation();
const a = document.createElement('a');
a.href = lightbox.url; a.download = lightbox.name; a.click();
}}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
Télécharger
</button>
<img
src={lightbox.url}
alt={lightbox.name}
className="comm-lightbox-img"
onClick={e => e.stopPropagation()}
/>
</div>
)}
{/* ── Modale nouveau ticket ── */}
{showCompose && (
<div className="comm-modal-backdrop" onClick={() => setShowCompose(false)}>
<div className="comm-modal" onClick={e => e.stopPropagation()}>
<div className="comm-modal-header">
<span style={{ fontWeight: 700, fontSize: 15 }}>Nouveau ticket de support</span>
<button className="comm-modal-close" onClick={() => setShowCompose(false)}>×</button>
</div>
<form onSubmit={submitTicket} className="comm-modal-body">
<div className="comm-form-group">
<label className="comm-label">Sujet *</label>
<input
className="comm-input"
value={composeSubject}
onChange={e => setComposeSubject(e.target.value)}
placeholder="Décrivez votre problème en quelques mots"
required
autoFocus
/>
</div>
<div className="comm-form-group">
<label className="comm-label">Message *</label>
<RichTextEditor
value={composeBody}
onChange={setComposeBody}
onImgExpand={(url, name) => setLightbox({ url, name })}
placeholder="Décrivez votre problème en détail… (Ctrl+V pour coller une image)"
minHeight={150}
/>
</div>
{composeFiles.length > 0 && (
<div className="comm-files-preview">
{composeFiles.map((f, i) => (
<FileChip key={i} file={f} onRemove={() => setComposeFiles(prev => prev.filter((_, j) => j !== i))} />
))}
</div>
)}
<div className="comm-reply-actions">
<button
type="button"
className="btn btn-ghost btn-sm"
onClick={() => fileInputRef.current?.click()}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
Joindre un fichier
</button>
<input
ref={fileInputRef}
type="file"
multiple
style={{ display: 'none' }}
onChange={e => setComposeFiles(prev => [...prev, ...Array.from(e.target.files)])}
/>
<div style={{ display: 'flex', gap: 6, alignItems: 'center', marginLeft: 'auto' }}>
<TagSelect label="Type" options={TICKET_TYPES} value={composeType} onChange={setComposeType} />
<TagSelect label="Page" options={TICKET_PAGES} value={composePages} onChange={setComposePages} multi />
<button type="submit" className="btn btn-primary btn-sm" disabled={composeSending === 'sending'}>
{composeSending === 'sending' ? 'Création…' : 'Créer le ticket'}
</button>
</div>
</div>
</form>
</div>
</div>
)}
</>
);
}