Fixe
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
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';
|
||||
@@ -189,13 +190,29 @@ const TYPE_COLORS = {
|
||||
// ── TagSelect ─────────────────────────────────────────────────────────────
|
||||
function TagSelect({ label, options, value, onChange, multi = false }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef(null);
|
||||
const [dropPos, setDropPos] = useState({ top: 0, left: 0, width: 0 });
|
||||
const triggerRef = useRef(null);
|
||||
const dropRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
const h = e => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
|
||||
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;
|
||||
|
||||
@@ -215,33 +232,61 @@ function TagSelect({ label, options, value, onChange, multi = false }) {
|
||||
: (value ? options.find(o => o.value === value)?.label ?? label : label);
|
||||
|
||||
return (
|
||||
<div className="tag-select" ref={ref}>
|
||||
<div className="tag-select" ref={triggerRef}>
|
||||
<button
|
||||
type="button"
|
||||
className={`tag-select-trigger${selectedCount > 0 ? ' has-value' : ''}${open ? ' open' : ''}`}
|
||||
onClick={() => setOpen(o => !o)}
|
||||
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 && (
|
||||
<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>
|
||||
{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>
|
||||
);
|
||||
@@ -530,7 +575,7 @@ export default function Communication() {
|
||||
const [tickets, setTickets] = useState([]);
|
||||
const [ticketSearch, setTicketSearch] = useState('');
|
||||
const [ticketFilter, setTicketFilter] = useState(new Set(['open', 'pending'])); // multi-sélection
|
||||
const [notifFilter, setNotifFilter] = useState('all'); // 'all'|'unread'
|
||||
const [notifFilter, setNotifFilter] = useState(new Set(['read', 'unread'])); // multi-sélection
|
||||
const [selectedTicket, setSelectedTicket] = useState(null);
|
||||
const [thread, setThread] = useState(null); // { ticket, messages }
|
||||
const [notifs, setNotifs] = useState([]);
|
||||
@@ -822,18 +867,6 @@ export default function Communication() {
|
||||
{/* Colonne 2 — Dossier + filtres */}
|
||||
<div className="comm-topbar-2">
|
||||
<span className="comm-topbar-folder" style={{ fontSize: 15, fontWeight: 700 }}>{tab === 'support' ? 'Support' : 'Notifications'}</span>
|
||||
{tab === 'notifications' && (
|
||||
<button
|
||||
className="btn-icon-sm"
|
||||
style={{ marginLeft: 'auto', flexShrink: 0 }}
|
||||
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>
|
||||
{/* Colonne 3 — Toolbar contextuelle */}
|
||||
<div className="comm-topbar-3">
|
||||
@@ -853,13 +886,17 @@ export default function Communication() {
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{tab === 'notifications' && selectedNotif && !selectedNotif.read && (
|
||||
<div style={{ display: 'flex', gap: 6, marginLeft: 'auto' }}>
|
||||
<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>
|
||||
{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>
|
||||
@@ -931,9 +968,33 @@ export default function Communication() {
|
||||
<>
|
||||
<div className="comm-list-header" style={{ flexDirection: 'column', alignItems: 'stretch', gap: 8 }}>
|
||||
<div className="comm-topbar-filters">
|
||||
{[['all','Tous'],['open','Ouverts'],['pending','En attente'],['resolved','Résolus']].map(([v,l]) => (
|
||||
<button key={v} className={`comm-topbar-filter-btn${ticketFilter === v ? ' active' : ''}`} onClick={() => setTicketFilter(v)}>{l}</button>
|
||||
))}
|
||||
{[['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"
|
||||
@@ -952,7 +1013,7 @@ export default function Communication() {
|
||||
</div>
|
||||
)}
|
||||
{tickets
|
||||
.filter(t => ticketFilter === 'all' || t.status === ticketFilter)
|
||||
.filter(t => ticketFilter.size === 0 || ticketFilter.has(t.status))
|
||||
.filter(t => {
|
||||
if (!ticketSearch.trim()) return true;
|
||||
const q = ticketSearch.toLowerCase();
|
||||
@@ -992,7 +1053,9 @@ export default function Communication() {
|
||||
|
||||
{tab === 'notifications' && (() => {
|
||||
const NOTIF_PER_PAGE = 10;
|
||||
const filteredNotifs = notifFilter === 'unread' ? notifs.filter(n => !n.read) : notifs;
|
||||
const filteredNotifs = notifFilter.size === 2 || notifFilter.size === 0
|
||||
? 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);
|
||||
@@ -1002,12 +1065,36 @@ export default function Communication() {
|
||||
<>
|
||||
<div className="comm-list-header">
|
||||
<div className="comm-topbar-filters">
|
||||
{[['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" style={{ marginLeft: 'auto' }} onClick={markAllRead}>Tout marquer lu</button>
|
||||
)}
|
||||
{[['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">
|
||||
@@ -1392,6 +1479,22 @@ export default function Communication() {
|
||||
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' }}
|
||||
@@ -1400,7 +1503,7 @@ export default function Communication() {
|
||||
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 marquer lu
|
||||
Tout considérer comme lu
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
|
||||
Reference in New Issue
Block a user