diff --git a/backend/src/jobs/autoTicketStatus.js b/backend/src/jobs/autoTicketStatus.js new file mode 100644 index 0000000..18fe102 --- /dev/null +++ b/backend/src/jobs/autoTicketStatus.js @@ -0,0 +1,136 @@ +import db from '../db/index.js'; + +const JOB_NAME = 'auto_ticket_status'; + +function writeLog({ status, nbChanges, details, errorMsg }) { + try { + db.prepare(` + INSERT INTO job_logs (job_name, status, nb_changes, details, error_msg) + VALUES (?, ?, ?, ?, ?) + `).run(JOB_NAME, status, nbChanges ?? 0, details ?? null, errorMsg ?? null); + } catch (e) { + console.error('[autoTicketStatus] Impossible d\'écrire dans job_logs :', e.message); + } +} + +/** + * Règles appliquées : + * + * 1. open → pending + * Ticket ouvert sans aucun message depuis plus de 2 jours. + * (updated_at sert d'horodatage de la dernière activité) + * + * 2. pending → resolved (clôture automatique) + * Ticket en attente où le créateur du ticket n'a pas posté de message + * depuis plus de 5 jours. + * On cherche le dernier message de l'émetteur (is_admin = 0 et user_id = ticket.user_id). + * Si ce message date de plus de 5 jours, le ticket est clôturé. + */ +export function checkTicketStatuses() { + let nbChanges = 0; + const details = []; + + // ── Règle 1 : open → pending (pas de réponse depuis 2 jours) ───────────── + const toPending = db.prepare(` + SELECT id, ticket_number, subject, updated_at + FROM tickets + WHERE status = 'open' + AND datetime(updated_at) < datetime('now', '-2 days') + `).all(); + + const setPending = db.prepare(` + UPDATE tickets SET status = 'pending', updated_at = datetime('now') WHERE id = ? + `); + + const notifyUserPending = db.prepare(` + INSERT INTO notifications (user_id, type, title, body, link) + SELECT user_id, 'ticket_reply', + '[' || ticket_number || '] Ticket mis en attente', + 'Votre ticket "' || subject || '" est passé en attente faute de réponse depuis 2 jours.', + '/communication?ticket=' || id + FROM tickets WHERE id = ? + `); + + for (const t of toPending) { + setPending.run(t.id); + try { notifyUserPending.run(t.id); } catch (_) { /* best-effort */ } + details.push(`[pending] ${t.ticket_number} (id=${t.id})`); + nbChanges++; + } + + // ── Règle 2 : pending → resolved (pas de réponse créateur depuis 5 jours) ─ + const pendingTickets = db.prepare(` + SELECT id, ticket_number, subject, user_id + FROM tickets + WHERE status = 'pending' + `).all(); + + const lastCreatorMsg = db.prepare(` + SELECT MAX(created_at) as last_at + FROM ticket_messages + WHERE ticket_id = ? AND user_id = ? AND is_admin = 0 + `); + + const setResolved = db.prepare(` + UPDATE tickets SET status = 'resolved', updated_at = datetime('now') WHERE id = ? + `); + + const notifyUserResolved = db.prepare(` + INSERT INTO notifications (user_id, type, title, body, link) + SELECT user_id, 'ticket_reply', + '[' || ticket_number || '] Ticket clôturé automatiquement', + 'Votre ticket "' || subject || '" a été clôturé automatiquement après 5 jours sans réponse.', + '/communication?ticket=' || id + FROM tickets WHERE id = ? + `); + + for (const t of pendingTickets) { + const row = lastCreatorMsg.get(t.id, t.user_id); + // Si jamais de message créateur, on prend la date de création du ticket (updated_at) + const refDate = row?.last_at ?? null; + if (!refDate) continue; // pas de message créateur trouvé, on ne clôture pas + + const ageDays = (Date.now() - new Date(refDate + 'Z').getTime()) / (1000 * 60 * 60 * 24); + if (ageDays > 5) { + setResolved.run(t.id); + try { notifyUserResolved.run(t.id); } catch (_) { /* best-effort */ } + details.push(`[resolved] ${t.ticket_number} (id=${t.id}, last_creator_msg=${refDate})`); + nbChanges++; + } + } + + const summary = nbChanges === 0 + ? 'Aucun ticket modifié' + : details.join('; '); + + writeLog({ status: 'ok', nbChanges, details: summary }); + if (nbChanges > 0) { + console.log(`[autoTicketStatus] ${nbChanges} ticket(s) mis à jour : ${summary}`); + } + return nbChanges; +} + +/** + * Démarre le job — exécution immédiate au démarrage, puis toutes les heures. + */ +export function startAutoTicketStatusJob() { + const INTERVAL_MS = 60 * 60 * 1000; // 1 heure + + try { + checkTicketStatuses(); + } catch (err) { + console.error('[autoTicketStatus] Erreur initiale :', err); + writeLog({ status: 'error', nbChanges: 0, errorMsg: err.message }); + } + + setInterval(() => { + try { + checkTicketStatuses(); + } catch (err) { + console.error('[autoTicketStatus] Erreur :', err); + writeLog({ status: 'error', nbChanges: 0, errorMsg: err.message }); + } + }, INTERVAL_MS); + + console.log('[autoTicketStatus] Job démarré — vérification toutes les heures'); +} diff --git a/backend/src/server.js b/backend/src/server.js index d94ccba..d3e7f66 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -33,6 +33,7 @@ import { errorHandler } from './middleware/errorHandler.js'; import { requireAuth, requireAdmin } from './middleware/auth.js'; import { startAutoStatutJob } from './jobs/autoStatut.js'; import { startAutoExportJob } from './jobs/autoExport.js'; +import { startAutoTicketStatusJob } from './jobs/autoTicketStatus.js'; import adminRouter from './routes/admin.js'; import invitationsRouter from './routes/invitations.js'; import auditLogsRouter from './routes/auditLogs.js'; @@ -141,4 +142,5 @@ app.listen(PORT, () => { console.log(`Crowdlending API listening on http://localhost:${PORT}`); startAutoStatutJob(); startAutoExportJob(); + startAutoTicketStatusJob(); }); diff --git a/frontend/src/pages/Communication.jsx b/frontend/src/pages/Communication.jsx index 36db335..9da2cfb 100644 --- a/frontend/src/pages/Communication.jsx +++ b/frontend/src/pages/Communication.jsx @@ -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 ( -
+
- {open && ( -
- {options.map(opt => ( - - ))} -
+ {open && createPortal( +
+ {options.map(opt => { + const sel = isSelected(opt.value); + return ( +
toggle(opt.value)} + > + {/* Bulle radio/checkbox custom */} + + {sel && ( + + {multi + ? + : + } + + )} + + {opt.label} +
+ ); + })} +
, + document.body )}
); @@ -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 */}
{tab === 'support' ? 'Support' : 'Notifications'} - {tab === 'notifications' && ( - - )}
{/* Colonne 3 — Toolbar contextuelle */}
@@ -853,13 +886,17 @@ export default function Communication() {
)} - {tab === 'notifications' && selectedNotif && !selectedNotif.read && ( -
- + {tab === 'notifications' && selectedNotif && ( +
+
)}
@@ -931,9 +968,33 @@ export default function Communication() { <>
- {[['all','Tous'],['open','Ouverts'],['pending','En attente'],['resolved','Résolus']].map(([v,l]) => ( - - ))} + {[['open','Ouverts'],['pending','En attente'],['resolved','Résolus']].map(([v,l]) => { + const active = ticketFilter.has(v); + return ( + + ); + })}
)} {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() { <>
- {[['all','Tous'],['unread','Non lus']].map(([v,l]) => ( - - ))} - {unreadCount > 0 && ( - - )} + {[['unread','Non lues'],['read','Lues']].map(([v,l]) => { + const active = notifFilter.has(v); + return ( + + ); + })}
@@ -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 && ( + + )} {unreadCount > 0 && ( )}