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 */}