Files
crowdlending-app/frontend/src/pages/Communication.jsx
T
2026-06-18 23:13:25 +02:00

2134 lines
105 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 { 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 (
<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' },
pending: { bg: '#fef9c3', color: '#a16207', label: 'En attente' },
};
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 [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 (
<div className="tag-select" ref={triggerRef}>
<button
type="button"
className={`tag-select-trigger${selectedCount > 0 ? ' has-value' : ''}${open ? ' open' : ''}`}
onClick={openDropdown}
>
<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 && createPortal(
<div
ref={dropRef}
className="tag-select-dropdown"
style={{
position: 'fixed',
top: dropPos.top,
left: dropPos.left,
minWidth: dropPos.width,
zIndex: 9999,
}}
>
{options.map(opt => {
const sel = isSelected(opt.value);
return (
<div
key={opt.value}
className={`tag-select-option${sel ? ' selected' : ''}`}
onClick={() => toggle(opt.value)}
>
{/* Bulle radio/checkbox custom */}
<span style={{
flexShrink: 0,
width: 14, height: 14,
borderRadius: multi ? 3 : '50%',
border: `2px solid ${sel ? 'var(--primary)' : 'var(--border)'}`,
background: sel ? 'var(--primary)' : 'transparent',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
{sel && (
<svg width="8" height="8" viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="3.5">
{multi
? <polyline points="20 6 9 17 4 12"/>
: <circle cx="12" cy="12" r="4" fill="#fff" stroke="none"/>
}
</svg>
)}
</span>
<span>{opt.label}</span>
</div>
);
})}
</div>,
document.body
)}
</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 ─────────────────────────────────────
// ── 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 (
<div ref={triggerRef} style={{ position: 'relative' }}>
<button
type="button"
onClick={openDrop}
style={{
display: 'flex', alignItems: 'center', gap: 10, width: '100%',
padding: '8px 12px', borderRadius: 8, cursor: 'pointer', fontSize: 13,
border: `2px solid ${meta.color}`, background: meta.bg, color: meta.color,
fontWeight: 600,
}}
>
<NotifTypeAvatar type={value} size={20} />
<span style={{ flex: 1, textAlign: 'left' }}>{meta.label}</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 && createPortal(
<div
ref={dropRef}
style={{
position: 'fixed', top: pos.top, left: pos.left, width: pos.width,
zIndex: 9999, background: 'var(--surface)', border: '1px solid var(--border)',
borderRadius: 8, boxShadow: '0 4px 20px rgba(0,0,0,0.15)', padding: '4px 0',
}}
>
{Object.entries(TYPE_META).map(([k, m]) => (
<div
key={k}
onClick={() => { 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'; }}
>
<span style={{
display: 'flex', alignItems: 'center', justifyContent: 'center',
width: 28, height: 28, borderRadius: '50%', background: m.bg, color: m.color, flexShrink: 0,
}}>
<NotifTypeAvatar type={k} size={16} />
</span>
<span>{m.label}</span>
{k === value && (
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" style={{ marginLeft: 'auto', color: m.color }}>
<polyline points="20 6 9 17 4 12"/>
</svg>
)}
</div>
))}
</div>,
document.body
)}
</div>
);
}
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>
);
}
// ── 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: (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="9" cy="7" r="4"/><path d="M3 21v-2a4 4 0 0 1 4-4h4"/><circle cx="17" cy="9" r="4" opacity=".5"/><path d="M21 21v-2a4 4 0 0 0-4-4h-1" opacity=".5"/></svg>
)},
{ value: 'admins', label: 'Tous les administrateurs', icon: (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M12 2l3 3h4v4l3 3-3 3v4h-4l-3 3-3-3H5v-4L2 12l3-3V5h4z"/></svg>
)},
];
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 (
<div ref={triggerRef}>
<button
type="button"
onClick={openDrop}
style={{
display: 'flex', alignItems: 'center', gap: 8, width: '100%',
padding: '8px 12px', borderRadius: 8, cursor: 'pointer', fontSize: 13,
border: '1px solid var(--border)', background: 'var(--bg-input, var(--surface-2))',
color: 'var(--text)',
}}
>
{triggerIcon && <span style={{ color: 'var(--text-muted)', flexShrink: 0 }}>{triggerIcon}</span>}
<span style={{ flex: 1, textAlign: 'left' }}>{triggerLabel}</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 && createPortal(
<div
ref={dropRef}
style={{
position: 'fixed', top: pos.top, left: pos.left, minWidth: pos.width,
zIndex: 9999, background: 'var(--surface)', border: '1px solid var(--border)',
borderRadius: 8, boxShadow: '0 4px 20px rgba(0,0,0,0.15)', overflow: 'hidden',
}}
>
{/* Champ recherche */}
<div style={{ padding: '8px 10px', borderBottom: '1px solid var(--border)' }}>
<input
ref={searchRef}
value={search}
onChange={e => 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',
}}
/>
</div>
{/* Options spéciales (pas filtrées) */}
<div style={{ maxHeight: 240, overflowY: 'auto' }}>
{SPECIAL.map(s => (
<div
key={s.value}
onClick={() => 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'; }}
>
<span style={{ color: value === s.value ? 'var(--primary)' : 'var(--text-muted)', flexShrink: 0 }}>{s.icon}</span>
<span style={{ flex: 1 }}>{s.label}</span>
{value === s.value && (
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" style={{ color: 'var(--primary)' }}>
<polyline points="20 6 9 17 4 12"/>
</svg>
)}
</div>
))}
{/* Séparateur */}
{filteredUsers.length > 0 && (
<div style={{ padding: '4px 12px 2px', fontSize: 10, fontWeight: 600, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.06em', borderTop: '1px solid var(--border)', marginTop: 2 }}>
Utilisateurs individuels
</div>
)}
{filteredUsers.map(u => {
const sel = String(u.id) === String(value);
const name = u.display_name ?? u.email;
return (
<div
key={u.id}
onClick={() => 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'; }}
>
<span style={{
width: 26, height: 26, borderRadius: '50%', background: 'var(--primary)', color: '#fff',
display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 11, fontWeight: 700, flexShrink: 0,
}}>
{name.charAt(0).toUpperCase()}
</span>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13, fontWeight: sel ? 600 : 400, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{name}</div>
{u.display_name && <div style={{ fontSize: 11, color: 'var(--text-muted)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{u.email}</div>}
</div>
{sel && (
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" style={{ color: 'var(--primary)' }}>
<polyline points="20 6 9 17 4 12"/>
</svg>
)}
</div>
);
})}
{filteredUsers.length === 0 && q && (
<div style={{ padding: '10px 12px', fontSize: 12, color: 'var(--text-muted)', textAlign: 'center' }}>Aucun résultat</div>
)}
</div>
</div>,
document.body
)}
</div>
);
}
// ══════════════════════════════════════════════════════════════════════════
// 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 (
<>
<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" style={{ fontSize: 15, fontWeight: 700 }}>{tab === 'support' ? 'Support' : 'Notifications'}</span>
</div>
{/* Colonne 3 — Toolbar contextuelle */}
<div className="comm-topbar-3">
{tab === 'support' && thread && (
<>
<div style={{ display: 'flex', gap: 6, marginLeft: 'auto', flexShrink: 0, alignItems: 'center' }}>
<button
className="btn-icon-sm"
onClick={e => {
const r = e.currentTarget.getBoundingClientRect();
setSupportMenuPos({ x: r.right, y: r.bottom + 4 });
}}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="5" r="1"/><circle cx="12" cy="12" r="1"/><circle cx="12" cy="19" r="1"/></svg>
</button>
</div>
</>
)}
{tab === 'notifications' && selectedNotif && (
<div style={{ display: 'flex', gap: 6, marginLeft: 'auto', alignItems: 'center' }}>
<button
className="btn-icon-sm"
onClick={e => {
const r = e.currentTarget.getBoundingClientRect();
setNotifMenuPos({ x: r.right, y: r.bottom + 4 });
}}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="5" r="1"/><circle cx="12" cy="12" r="1"/><circle cx="12" cy="19" r="1"/></svg>
</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');
}}
>
<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" style={{ flexDirection: 'column', alignItems: 'stretch', gap: 8 }}>
<div className="comm-topbar-filters">
{[['open','Ouverts'],['pending','En attente'],['resolved','Résolus']].map(([v,l]) => {
const active = ticketFilter.has(v);
return (
<button
key={v}
className="comm-topbar-filter-btn"
style={active ? {
background: 'var(--primary)',
color: '#fff',
borderColor: 'var(--primary)',
display: 'flex', alignItems: 'center', gap: 5,
} : {}}
onClick={() => setTicketFilter(prev => {
const next = new Set(prev);
if (next.has(v)) { next.delete(v); } else { next.add(v); }
return next;
})}
>
{active && (
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" style={{ flexShrink: 0 }}>
<polyline points="20 6 9 17 4 12"/>
</svg>
)}
{l}
</button>
);
})}
</div>
<input
className="comm-search"
placeholder="Rechercher un ticket…"
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 => 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 => (
<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} />
{t.last_body && (
<div className="comm-list-row-preview" style={{ visibility: 'hidden' }}>{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.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 (
<>
<div className="comm-list-header">
<div className="comm-topbar-filters">
{[['unread','Non lues'],['read','Lues']].map(([v,l]) => {
const active = notifFilter.has(v);
return (
<button
key={v}
className="comm-topbar-filter-btn"
style={active ? {
background: 'var(--primary)',
color: '#fff',
borderColor: 'var(--primary)',
display: 'flex', alignItems: 'center', gap: 5,
} : {}}
onClick={() => {
setNotifFilter(prev => {
const next = new Set(prev);
if (next.has(v)) { next.delete(v); } else { next.add(v); }
return next;
});
setNotifPage(0);
}}
>
{active && (
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" style={{ flexShrink: 0 }}>
<polyline points="20 6 9 17 4 12"/>
</svg>
)}
{l}
</button>
);
})}
</div>
</div>
<div className="comm-list-scroll">
{notifs.length === 0 && (
<div className="comm-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 }}>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>
</div>
<div>
<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>
<div className="comm-list-row-preview" style={{ visibility: 'hidden' }}>{n.body ? 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" style={{ flexDirection: 'column', alignItems: 'stretch', gap: 0 }}>
{/* Ligne 1 : type chip + titre + badge statut */}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 2, flexWrap: 'wrap' }}>
{thread.ticket.ticket_type && (() => {
const type = TICKET_TYPES.find(t => t.value === thread.ticket.ticket_type);
return type ? (
<span className="ticket-chip ticket-chip-type" style={TYPE_COLORS[thread.ticket.ticket_type] || {}}>
{type.label}
</span>
) : null;
})()}
<span style={{ fontWeight: 700, fontSize: 15, flex: 1, minWidth: 0 }}>
{thread.ticket.ticket_number} {thread.ticket.subject}
</span>
<StatusBadge status={thread.ticket.status} />
</div>
{/* 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 (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginTop: 25 }}>
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>
Ouvert par <strong style={{ color: 'var(--text)' }}>{thread.ticket.user_name}</strong> · {fmtDate(thread.ticket.created_at)}
{thread.ticket.assigned_name && (
<> assigné à <strong style={{ color: 'var(--text)' }}>{thread.ticket.assigned_name}</strong></>
)}
</span>
<div className="ticket-chips" style={{ margin: 0 }}>
{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>
</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={{
background: '#fff',
borderTop: '1px solid var(--border)',
borderBottom: '1px solid var(--border)',
padding: '16px 24px',
height: 250,
overflowY: 'auto',
color: 'var(--text)',
lineHeight: 1.6,
fontSize: 14,
}}>
{selectedNotif.body}
</div>
)}
<div style={{
display: 'flex',
justifyContent: 'flex-end',
alignItems: 'center',
gap: 8,
padding: '10px 16px',
background: '#fff',
borderBottom: '1px solid var(--border)',
}}>
<button
className="btn btn-sm btn-ghost"
style={{ color: 'var(--danger)' }}
onClick={() => deleteNotif(selectedNotif.id)}
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" style={{ marginRight: 5 }}><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14H6L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4h6v2"/></svg>
Supprimer
</button>
{selectedNotif.link && (
<button className="btn btn-primary btn-sm" onClick={() => navigate(selectedNotif.link)}>
Voir le détail
</button>
)}
</div>
</div>
)}
{/* 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 (
<div style={{ background: '#fff', height: '100%', display: 'flex', flexDirection: 'column' }}>
<div style={{ padding: '28px 28px 20px' }}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 16 }}>
{kpis.map(k => (
<div key={k.label} style={{ background: k.bg, borderRadius: 12, padding: '24px 16px', textAlign: 'center' }}>
<div style={{ fontSize: 32, fontWeight: 700, color: k.color, lineHeight: 1 }}>{k.count}</div>
<div style={{ marginTop: 8, fontSize: 13, fontWeight: 600, color: k.color }}>{k.label}</div>
</div>
))}
</div>
</div>
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 12 }}>
<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={{ color: 'var(--text-muted)', fontSize: 14 }}>Sélectionnez un ticket</div>
</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 */}
{/* ── Modale Nouvelle notification (admin) ── */}
{showBroadcastForm && isAdmin && (() => {
const selectedMeta = TYPE_META[bcType] ?? TYPE_META.info;
return (
<div className="comm-modal-backdrop" onClick={() => setShowBroadcastForm(false)}>
<div className="comm-modal" style={{ maxWidth: 500 }} onClick={e => e.stopPropagation()}>
<div className="comm-modal-header">
<span style={{ fontWeight: 700, fontSize: 15 }}>Nouvelle notification</span>
<button className="comm-modal-close" onClick={() => setShowBroadcastForm(false)}>×</button>
</div>
<form onSubmit={submitBroadcast}>
<div style={{ padding: '16px 20px', display: 'flex', flexDirection: 'column', gap: 14 }}>
{/* Type — liste déroulante custom */}
<div>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 6 }}>Type</div>
<TypeDropdown value={bcType} onChange={setBcType} />
</div>
{/* Destinataire — dropdown searchable */}
<div>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 6 }}>Destinataire</div>
<RecipientDropdown value={bcUserId} onChange={setBcUserId} users={users} />
</div>
{/* Titre */}
<div>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 6 }}>Titre *</div>
<input
className="comm-input"
style={{ width: '100%', boxSizing: 'border-box' }}
value={bcTitle}
onChange={e => setBcTitle(e.target.value)}
placeholder="Titre de la notification"
autoFocus
required
/>
</div>
{/* Message */}
<div>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 6 }}>Message <span style={{ fontWeight: 400, textTransform: 'none' }}>(optionnel)</span></div>
<RichTextEditor
value={bcBody}
onChange={setBcBody}
onImgExpand={(url, name) => setLightbox({ url, name })}
placeholder="Corps du message… (Ctrl+V pour coller une image)"
minHeight={120}
/>
</div>
{bcResult && (
<div style={{ fontSize: 13, color: bcResult.ok ? 'var(--success)' : 'var(--danger)' }}>
{bcResult.msg}
</div>
)}
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, padding: '0 20px 16px' }}>
<button type="button" className="btn btn-ghost" onClick={() => setShowBroadcastForm(false)}>Annuler</button>
<button
type="submit"
className="btn btn-primary"
disabled={bcSending || !bcTitle.trim()}
style={{ background: selectedMeta.color, border: 'none' }}
>
{bcSending ? 'Envoi…' : 'Envoyer'}
</button>
</div>
</form>
</div>
</div>
);
})()}
{/* ── Menu ⋮ notifications ── */}
{notifMenuPos && (
<>
<div style={{ position: 'fixed', inset: 0, zIndex: 299 }} onClick={() => setNotifMenuPos(null)} />
<div style={{
position: 'fixed', left: notifMenuPos.x, top: notifMenuPos.y,
transform: 'translateX(-100%) translateY(4px)',
zIndex: 300,
background: 'var(--surface)', border: '1px solid var(--border)',
borderRadius: 8, boxShadow: '0 4px 20px rgba(0,0,0,0.15)',
padding: '4px 0', minWidth: 180,
}}>
{selectedNotif && !selectedNotif.read && (
<button
style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%', padding: '8px 14px', background: 'none', border: 'none', cursor: 'pointer', fontSize: 'var(--fs-sm)', color: 'var(--text)', textAlign: 'left' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--surface-2)'}
onMouseLeave={e => e.currentTarget.style.background = 'none'}
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 }));
setNotifMenuPos(null);
}}
>
<svg 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>
Marquer comme lu
</button>
)}
{unreadCount > 0 && (
<button
style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%', padding: '8px 14px', background: 'none', border: 'none', cursor: 'pointer', fontSize: 'var(--fs-sm)', color: 'var(--text)', textAlign: 'left' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--surface-2)'}
onMouseLeave={e => e.currentTarget.style.background = 'none'}
onClick={() => { markAllRead(); setNotifMenuPos(null); }}
>
<svg 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>
Tout considérer comme lu
</button>
)}
<button
style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%', padding: '8px 14px', background: 'none', border: 'none', cursor: 'pointer', fontSize: 'var(--fs-sm)', color: 'var(--danger)', textAlign: 'left' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--surface-2)'}
onMouseLeave={e => e.currentTarget.style.background = 'none'}
onClick={() => { deleteAllNotifs(); setNotifMenuPos(null); }}
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><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 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>
Tout supprimer
</button>
</div>
</>
)}
{/* ── Menu ⋮ support ── */}
{supportMenuPos && (
<>
<div style={{ position: 'fixed', inset: 0, zIndex: 299 }} onClick={() => setSupportMenuPos(null)} />
<div style={{
position: 'fixed', left: supportMenuPos.x, top: supportMenuPos.y,
transform: 'translateX(-100%) translateY(4px)',
zIndex: 300,
background: 'var(--surface)', border: '1px solid var(--border)',
borderRadius: 8, boxShadow: '0 4px 20px rgba(0,0,0,0.15)',
padding: '4px 0', minWidth: 180,
}}>
<button
style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%', padding: '8px 14px', background: 'none', border: 'none', cursor: 'pointer', fontSize: 'var(--fs-sm)', color: 'var(--text)', textAlign: 'left' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--surface-2)'}
onMouseLeave={e => e.currentTarget.style.background = 'none'}
onClick={() => { setShowCompose(true); setSupportMenuPos(null); }}
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M12 5v14"/><path d="M5 12h14"/></svg>
Nouveau ticket
</button>
{thread && (
<button
style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%', padding: '8px 14px', background: 'none', border: 'none', cursor: 'pointer', fontSize: 'var(--fs-sm)', color: 'var(--text)', textAlign: 'left' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--surface-2)'}
onMouseLeave={e => e.currentTarget.style.background = 'none'}
onClick={() => {
setCategType(thread.ticket.ticket_type ?? null);
const p = thread.ticket.ticket_pages;
setCategPages(p ? (Array.isArray(p) ? p : JSON.parse(p)) : []);
setShowCategModal(true);
setSupportMenuPos(null);
}}
>
<svg width="13" height="13" 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>
Modifier la catégorisation
</button>
)}
{thread && isAdmin && (
<>
<button
style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%', padding: '8px 14px', background: 'none', border: 'none', cursor: 'pointer', fontSize: 'var(--fs-sm)', color: 'var(--text)', textAlign: 'left' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--surface-2)'}
onMouseLeave={e => e.currentTarget.style.background = 'none'}
onClick={() => { setAssignSearch(''); setShowAssignModal(true); setSupportMenuPos(null); }}
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
Assigner à
</button>
{thread.ticket.status !== 'pending' ? (
<button
style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%', padding: '8px 14px', background: 'none', border: 'none', cursor: 'pointer', fontSize: 'var(--fs-sm)', color: 'var(--text)', textAlign: 'left' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--surface-2)'}
onMouseLeave={e => e.currentTarget.style.background = 'none'}
onClick={() => { setTicketPending(); setSupportMenuPos(null); }}
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
Mettre en attente
</button>
) : (
<button
style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%', padding: '8px 14px', background: 'none', border: 'none', cursor: 'pointer', fontSize: 'var(--fs-sm)', color: 'var(--text)', textAlign: 'left' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--surface-2)'}
onMouseLeave={e => e.currentTarget.style.background = 'none'}
onClick={() => { toggleStatus(); setSupportMenuPos(null); }}
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 .49-3.5"/></svg>
Réouvrir le ticket
</button>
)}
<button
style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%', padding: '8px 14px', background: 'none', border: 'none', cursor: 'pointer', fontSize: 'var(--fs-sm)', color: 'var(--text)', textAlign: 'left' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--surface-2)'}
onMouseLeave={e => e.currentTarget.style.background = 'none'}
onClick={() => { toggleStatus(); setSupportMenuPos(null); }}
>
{thread.ticket.status === 'resolved'
? <><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 .49-3.5"/></svg>Rouvrir</>
: <><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="20 6 9 17 4 12"/></svg>Clôturer le ticket</>
}
</button>
</>
)}
{thread && (
<>
<div style={{ borderTop: '1px solid var(--border)', margin: '4px 0' }} />
<button
style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%', padding: '8px 14px', background: 'none', border: 'none', cursor: 'pointer', fontSize: 'var(--fs-sm)', color: 'var(--text-muted)', textAlign: 'left' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--surface-2)'}
onMouseLeave={e => e.currentTarget.style.background = 'none'}
onClick={() => { setThread(null); setSelectedTicket(null); setSearchParams({}, { replace: true }); setSupportMenuPos(null); }}
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
Fermer
</button>
</>
)}
</div>
</>
)}
{/* ── Modale Assigner à ── */}
{showAssignModal && (
<div className="comm-modal-backdrop" onClick={() => setShowAssignModal(false)}>
<div className="comm-modal" style={{ maxWidth: 420 }} onClick={e => e.stopPropagation()}>
<div className="comm-modal-header">
<span style={{ fontWeight: 700, fontSize: 15 }}>Assigner le ticket</span>
<button className="comm-modal-close" onClick={() => setShowAssignModal(false)}>×</button>
</div>
<div style={{ padding: '12px 20px 16px' }}>
<input
className="comm-search"
placeholder="Rechercher un administrateur…"
value={assignSearch}
onChange={e => setAssignSearch(e.target.value)}
autoFocus
style={{ width: '100%', marginBottom: 10 }}
/>
{/* Désassigner */}
{thread?.ticket.assigned_to && (
<button
style={{ display: 'flex', alignItems: 'center', gap: 10, width: '100%', padding: '8px 12px', background: 'none', border: 'none', cursor: 'pointer', fontSize: 13, color: 'var(--danger)', borderRadius: 6, marginBottom: 4 }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--surface-2)'}
onMouseLeave={e => e.currentTarget.style.background = 'none'}
onClick={async () => { await assignTicket(null); setShowAssignModal(false); }}
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
Retirer l'assignation
</button>
)}
{/* Liste admins */}
<div style={{ maxHeight: 260, overflowY: 'auto' }}>
{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 (
<button
key={u.id}
style={{ display: 'flex', alignItems: 'center', gap: 10, width: '100%', padding: '8px 12px', background: isAssigned ? 'var(--surface-2)' : 'none', border: 'none', cursor: 'pointer', fontSize: 13, color: 'var(--text)', borderRadius: 6, textAlign: 'left' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--surface-2)'}
onMouseLeave={e => e.currentTarget.style.background = isAssigned ? 'var(--surface-2)' : 'none'}
onClick={async () => { await assignTicket(u.id); setShowAssignModal(false); }}
>
<UserAvatar name={u.display_name ?? u.email} isAdmin size={30} />
<div>
<div style={{ fontWeight: 600 }}>{u.display_name ?? u.email}</div>
{u.display_name && <div style={{ fontSize: 11, color: 'var(--text-muted)' }}>{u.email}</div>}
</div>
{isAssigned && (
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="var(--primary)" strokeWidth="2.5" style={{ marginLeft: 'auto' }}><polyline points="20 6 9 17 4 12"/></svg>
)}
</button>
);
})
}
</div>
</div>
</div>
</div>
)}
{/* ── Modale catégorisation ticket ── */}
{showCategModal && thread && (
<div className="comm-modal-backdrop" onClick={() => setShowCategModal(false)}>
<div className="comm-modal" style={{ maxWidth: 420 }} onClick={e => e.stopPropagation()}>
<div className="comm-modal-header">
<span style={{ fontWeight: 700, fontSize: 15 }}>Modifier la catégorisation</span>
<button className="comm-modal-close" onClick={() => setShowCategModal(false)}>×</button>
</div>
<div style={{ padding: '16px 20px 20px', display: 'flex', flexDirection: 'column', gap: 16 }}>
<div>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-muted)', marginBottom: 6, textTransform: 'uppercase', letterSpacing: '0.05em' }}>Type</div>
<TagSelect label="Type" options={TICKET_TYPES} value={categType} onChange={setCategType} />
</div>
<div>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-muted)', marginBottom: 6, textTransform: 'uppercase', letterSpacing: '0.05em' }}>Pages concernées</div>
<TagSelect label="Pages" options={TICKET_PAGES} value={categPages} onChange={setCategPages} multi />
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, marginTop: 4 }}>
<button className="btn btn-ghost" onClick={() => setShowCategModal(false)}>Annuler</button>
<button
className="btn btn-primary"
onClick={async () => {
try {
await api.patch(`/tickets/${thread.ticket.id}/categorize`, {
ticket_type: categType,
ticket_pages: categPages.length ? JSON.stringify(categPages) : null,
});
setThread(prev => ({
...prev,
ticket: {
...prev.ticket,
ticket_type: categType,
ticket_pages: categPages.length ? categPages : null,
},
}));
setTickets(prev => prev.map(t => t.id === thread.ticket.id
? { ...t, ticket_type: categType, ticket_pages: categPages.length ? JSON.stringify(categPages) : null }
: t
));
setShowCategModal(false);
} catch { /* silencieux */ }
}}
>
Enregistrer
</button>
</div>
</div>
</div>
</div>
)}
{/* ── 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>
)}
</>
);
}