2134 lines
105 KiB
React
2134 lines
105 KiB
React
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>
|
||
)}
|
||
</>
|
||
);
|
||
}
|