Compare commits

..

3 Commits

Author SHA1 Message Date
ocroguennec b2e73b4fe7 amélrioration 2026-06-18 21:45:05 +02:00
ocroguennec fea1d787ca fix 2026-06-18 21:04:54 +02:00
ocroguennec e94401dce8 fix 2026-06-18 20:47:44 +02:00
3 changed files with 365 additions and 62 deletions
+9
View File
@@ -2003,4 +2003,13 @@ console.log('[DB] Migrations 2FA OK');
}
}
{
// Migration : assigned_to sur tickets
const cols = db.prepare("PRAGMA table_info(tickets)").all().map(c => c.name);
if (!cols.includes('assigned_to')) {
db.exec("ALTER TABLE tickets ADD COLUMN assigned_to INTEGER REFERENCES users(id) ON DELETE SET NULL");
console.log('[DB] tickets: colonne assigned_to ajoutée');
}
}
export default db;
+27 -4
View File
@@ -144,8 +144,11 @@ router.get('/:id', (req, res) => {
const isAdmin = role === 'admin';
const ticket = db.prepare(`
SELECT t.*, u.display_name as user_name, u.email as user_email
FROM tickets t JOIN users u ON u.id = t.user_id
SELECT t.*, u.display_name as user_name, u.email as user_email,
a.display_name as assigned_name
FROM tickets t
JOIN users u ON u.id = t.user_id
LEFT JOIN users a ON a.id = t.assigned_to
WHERE t.id = ?
`).get(req.params.id);
@@ -194,7 +197,12 @@ router.post('/:id/messages', upload.array('attachments', 10), (req, res) => {
}
}
db.prepare("UPDATE tickets SET updated_at = datetime('now') WHERE id = ?").run(ticket.id);
// Réouverture automatique si l'émetteur répond sur un ticket en attente
if (!isAdmin && ticket.status === 'pending') {
db.prepare("UPDATE tickets SET status = 'open', updated_at = datetime('now') WHERE id = ?").run(ticket.id);
} else {
db.prepare("UPDATE tickets SET updated_at = datetime('now') WHERE id = ?").run(ticket.id);
}
return messageId;
});
@@ -224,7 +232,7 @@ router.post('/:id/messages', upload.array('attachments', 10), (req, res) => {
// ── PATCH /api/tickets/:id/status — résoudre / rouvrir (admin) ────────────
router.patch('/:id/status', requireAdmin, (req, res) => {
const { status } = req.body;
if (!['open', 'resolved'].includes(status)) return res.status(400).json({ error: 'Statut invalide' });
if (!['open', 'resolved', 'pending'].includes(status)) return res.status(400).json({ error: 'Statut invalide' });
const ticket = db.prepare('SELECT id FROM tickets WHERE id = ?').get(req.params.id);
if (!ticket) return res.status(404).json({ error: 'Ticket introuvable' });
@@ -234,6 +242,21 @@ router.patch('/:id/status', requireAdmin, (req, res) => {
res.json({ ok: true });
});
// ── PATCH /api/tickets/:id/assign — assigner à un admin ──────────────────────
router.patch('/:id/assign', requireAdmin, (req, res) => {
const { assigned_to } = req.body; // null pour désassigner
if (assigned_to !== null && assigned_to !== undefined) {
const admin = db.prepare("SELECT id FROM users WHERE id = ? AND role = 'admin'").get(assigned_to);
if (!admin) return res.status(400).json({ error: 'Utilisateur invalide ou non admin' });
}
const ticket = db.prepare('SELECT id FROM tickets WHERE id = ?').get(req.params.id);
if (!ticket) return res.status(404).json({ error: 'Ticket introuvable' });
db.prepare("UPDATE tickets SET assigned_to = ?, updated_at = datetime('now') WHERE id = ?")
.run(assigned_to ?? null, req.params.id);
res.json({ ok: true });
});
// ── PUT /api/tickets/:id/messages/:msgId — éditer un message (5 min) ────────
router.put('/:id/messages/:msgId', (req, res) => {
const { id: userId } = req.user;
+329 -58
View File
@@ -49,6 +49,7 @@ 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 (
@@ -510,6 +511,10 @@ 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 [assignSearch, setAssignSearch] = useState('');
const [tab, setTab] = useState('support'); // 'support' | 'notifications'
const [showBroadcastForm, setShowBroadcastForm] = useState(false);
@@ -524,7 +529,7 @@ export default function Communication() {
}, []);
const [tickets, setTickets] = useState([]);
const [ticketSearch, setTicketSearch] = useState('');
const [ticketFilter, setTicketFilter] = useState('all'); // 'all'|'open'|'resolved'
const [ticketFilter, setTicketFilter] = useState(new Set(['open', 'pending'])); // multi-sélection
const [notifFilter, setNotifFilter] = useState('all'); // 'all'|'unread'
const [selectedTicket, setSelectedTicket] = useState(null);
const [thread, setThread] = useState(null); // { ticket, messages }
@@ -595,7 +600,7 @@ export default function Communication() {
if (!isAdmin) return;
try {
const data = await api.get('/admin/users');
setUsers(data.users ?? data ?? []);
setUsers((data.users ?? data ?? []).filter(u => u.role === 'admin'));
} catch { /* silencieux */ }
}, [isAdmin]);
@@ -695,6 +700,25 @@ export default function Communication() {
} 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();
@@ -718,6 +742,26 @@ export default function Communication() {
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 {
@@ -777,55 +821,45 @@ export default function Communication() {
</div>
{/* Colonne 2 — Dossier + filtres */}
<div className="comm-topbar-2">
<span className="comm-topbar-folder">{tab === 'support' ? 'Support' : 'Notifications'}</span>
<div className="comm-topbar-filters">
{tab === 'support' && (
<>
{[['all','Tous'],['open','Ouverts'],['resolved','Résolus']].map(([v,l]) => (
<button key={v} className={`comm-topbar-filter-btn${ticketFilter === v ? ' active' : ''}`} onClick={() => setTicketFilter(v)}>{l}</button>
))}
</>
)}
{tab === 'notifications' && (
<>
{[['all','Tous'],['unread','Non lus']].map(([v,l]) => (
<button key={v} className={`comm-topbar-filter-btn${notifFilter === v ? ' active' : ''}`} onClick={() => { setNotifFilter(v); setNotifPage(0); }}>{l}</button>
))}
{unreadCount > 0 && (
<button className="comm-topbar-action-btn" onClick={markAllRead}>Tout marquer lu</button>
)}
</>
)}
</div>
<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">
{tab === 'support' && thread && (
<>
<span className="comm-topbar-ticket-ref">{thread.ticket.ticket_number} {thread.ticket.subject}</span>
<div style={{ display: 'flex', gap: 6, marginLeft: 'auto', flexShrink: 0 }}>
{isAdmin && (
<button className="btn btn-sm" onClick={toggleStatus}>
{thread.ticket.status === 'open' ? 'Résoudre' : 'Rouvrir'}
</button>
)}
<div 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 && (
{tab === 'notifications' && selectedNotif && !selectedNotif.read && (
<div style={{ display: 'flex', gap: 6, marginLeft: 'auto' }}>
{!selectedNotif.read && (
<button className="btn btn-sm btn-ghost" onClick={async () => {
await api.patch(`/notifications/${selectedNotif.id}/read`);
setNotifs(prev => prev.map(n => n.id === selectedNotif.id ? { ...n, read: 1 } : n));
setSelectedNotif(prev => ({ ...prev, read: 1 }));
}}>Marquer lu</button>
)}
{selectedNotif.link && (
<button className="btn btn-sm btn-primary" onClick={() => navigate(selectedNotif.link)}>
Voir le détail
</button>
)}
<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>
</div>
)}
</div>
@@ -895,11 +929,15 @@ export default function Communication() {
<div className="comm-list">
{tab === 'support' && (
<>
<div className="comm-list-header">
<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>
))}
</div>
<input
className="comm-search"
placeholder="Rechercher un ticket…"
style={{ flex: 1 }}
value={ticketSearch}
onChange={e => setTicketSearch(e.target.value)}
/>
@@ -914,6 +952,7 @@ export default function Communication() {
</div>
)}
{tickets
.filter(t => ticketFilter === 'all' || t.status === ticketFilter)
.filter(t => {
if (!ticketSearch.trim()) return true;
const q = ticketSearch.toLowerCase();
@@ -962,13 +1001,20 @@ export default function Communication() {
return (
<>
<div className="comm-list-header">
<span style={{ fontWeight: 600, fontSize: 14 }}>Notifications</span>
<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>
)}
</div>
</div>
<div className="comm-list-scroll">
{notifs.length === 0 && (
<div className="comm-empty">
<div style={{ fontSize: 32, marginBottom: 8 }}>🔔</div>
<div style={{ fontWeight: 600 }}>Aucune notification</div>
<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 => (
@@ -981,6 +1027,8 @@ export default function Communication() {
<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,
@@ -1200,17 +1248,43 @@ export default function Communication() {
</div>
</div>
{selectedNotif.body && (
<div style={{ padding: '20px 24px', color: 'var(--text)', lineHeight: 1.6 }}>
<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>
)}
{selectedNotif.link && (
<div style={{ padding: '0 24px 20px' }}>
<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>
</div>
)}
@@ -1268,13 +1342,33 @@ export default function Communication() {
</div>
)}
{/* Placeholder vide */}
{tab === 'support' && !thread && (
<div className="comm-detail-empty">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="var(--border)" strokeWidth="1.5"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
<div style={{ marginTop: 12, color: 'var(--text-muted)', fontSize: 14 }}>Sélectionnez un ticket</div>
</div>
)}
{/* 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>
@@ -1286,6 +1380,183 @@ export default function Communication() {
</div>{/* end comm-wrap */}
</div>{/* end comm-page */}
{/* ── 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,
}}>
{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 marquer 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 && 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>
<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>
)}
{/* ── Lightbox image ── */}
{lightbox && (
<div className="comm-lightbox" onClick={() => setLightbox(null)}>