From b2e73b4fe7539ff3f544fe10285b19f9761b7285 Mon Sep 17 00:00:00 2001 From: Olivier Date: Thu, 18 Jun 2026 21:45:05 +0200 Subject: [PATCH] =?UTF-8?q?am=C3=A9lrioration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/db/index.js | 9 + backend/src/routes/tickets.js | 31 +++- frontend/src/pages/Communication.jsx | 240 ++++++++++++++++++++++++--- 3 files changed, 249 insertions(+), 31 deletions(-) diff --git a/backend/src/db/index.js b/backend/src/db/index.js index 7ca3a22..deafeaf 100644 --- a/backend/src/db/index.js +++ b/backend/src/db/index.js @@ -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; diff --git a/backend/src/routes/tickets.js b/backend/src/routes/tickets.js index 256eafe..ee53e88 100644 --- a/backend/src/routes/tickets.js +++ b/backend/src/routes/tickets.js @@ -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; diff --git a/frontend/src/pages/Communication.jsx b/frontend/src/pages/Communication.jsx index 865b7e1..36db335 100644 --- a/frontend/src/pages/Communication.jsx +++ b/frontend/src/pages/Communication.jsx @@ -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,7 +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 [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); @@ -525,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 } @@ -596,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]); @@ -696,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(); @@ -799,13 +822,6 @@ export default function Communication() { {/* Colonne 2 — Dossier + filtres */}
{tab === 'support' ? 'Support' : 'Notifications'} - {tab === 'support' && ( -
- {[['all','Tous'],['open','Ouverts'],['resolved','Résolus']].map(([v,l]) => ( - - ))} -
- )} {tab === 'notifications' && ( - )} +
+
)} @@ -909,11 +929,15 @@ export default function Communication() {
{tab === 'support' && ( <> -
+
+
+ {[['all','Tous'],['open','Ouverts'],['pending','En attente'],['resolved','Résolus']].map(([v,l]) => ( + + ))} +
setTicketSearch(e.target.value)} /> @@ -928,6 +952,7 @@ export default function Communication() {
)} {tickets + .filter(t => ticketFilter === 'all' || t.status === ticketFilter) .filter(t => { if (!ticketSearch.trim()) return true; const q = ticketSearch.toLowerCase(); @@ -988,8 +1013,8 @@ export default function Communication() {
{notifs.length === 0 && (
-
🔔
-
Aucune notification
+ +
Aucune notification
)} {pagedNotifs.map(n => ( @@ -1317,13 +1342,33 @@ export default function Communication() {
)} - {/* Placeholder vide */} - {tab === 'support' && !thread && ( -
- -
Sélectionnez un ticket
-
- )} + {/* 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 ( +
+
+
+ {kpis.map(k => ( +
+
{k.count}
+
{k.label}
+
+ ))} +
+
+
+ +
Sélectionnez un ticket
+
+
+ ); + })()} {tab === 'notifications' && !selectedNotif && !showBroadcastForm && (
@@ -1371,6 +1416,147 @@ export default function Communication() { )} + {/* ── Menu ⋮ support ── */} + {supportMenuPos && ( + <> +
setSupportMenuPos(null)} /> +
+ + {thread && isAdmin && ( + <> + + {thread.ticket.status !== 'pending' ? ( + + ) : ( + + )} + +
+ + + )} +
+ + )} + + {/* ── Modale Assigner à ── */} + {showAssignModal && ( +
setShowAssignModal(false)}> +
e.stopPropagation()}> +
+ Assigner le ticket + +
+
+ setAssignSearch(e.target.value)} + autoFocus + style={{ width: '100%', marginBottom: 10 }} + /> + {/* Désassigner */} + {thread?.ticket.assigned_to && ( + + )} + {/* Liste admins */} +
+ {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 ( + + ); + }) + } +
+
+
+
+ )} + {/* ── Lightbox image ── */} {lightbox && (
setLightbox(null)}>