1381 lines
65 KiB
React
1381 lines
65 KiB
React
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>
|
||
)}
|
||
</>
|
||
);
|
||
}
|