From 5bbbbfa95674e87f4379e04db8e767732b75cc8e Mon Sep 17 00:00:00 2001 From: Olivier Date: Thu, 18 Jun 2026 20:26:23 +0200 Subject: [PATCH] Centre de notification --- backend/src/db/index.js | 105 ++ backend/src/routes/notifications.js | 137 ++ backend/src/routes/tickets.js | 281 ++++ backend/src/server.js | 4 + frontend/src/App.jsx | 4 + frontend/src/api.js | 2 + frontend/src/components/Layout.jsx | 3 + frontend/src/components/NotifTypeAvatar.jsx | 97 ++ frontend/src/components/NotificationBell.jsx | 177 +++ frontend/src/components/UserMenu.jsx | 6 + frontend/src/pages/Communication.jsx | 1378 ++++++++++++++++++ frontend/src/pages/Notifications.jsx | 291 ++++ frontend/src/styles.css | 1200 +++++++++++++++ 13 files changed, 3685 insertions(+) create mode 100644 backend/src/routes/notifications.js create mode 100644 backend/src/routes/tickets.js create mode 100644 frontend/src/components/NotifTypeAvatar.jsx create mode 100644 frontend/src/components/NotificationBell.jsx create mode 100644 frontend/src/pages/Communication.jsx create mode 100644 frontend/src/pages/Notifications.jsx diff --git a/backend/src/db/index.js b/backend/src/db/index.js index cc0626f..7ca3a22 100644 --- a/backend/src/db/index.js +++ b/backend/src/db/index.js @@ -1898,4 +1898,109 @@ console.log('[DB] Migrations 2FA OK'); } } +// ── Table notifications ─────────────────────────────────────────────────── +{ + const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='notifications'").get(); + if (!tables) { + db.exec(` + CREATE TABLE notifications ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + type TEXT NOT NULL DEFAULT 'system', + title TEXT NOT NULL, + body TEXT, + link TEXT, + read INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ) + `); + db.exec('CREATE INDEX IF NOT EXISTS idx_notif_user_id ON notifications(user_id)'); + db.exec('CREATE INDEX IF NOT EXISTS idx_notif_read ON notifications(user_id, read)'); + db.exec('CREATE INDEX IF NOT EXISTS idx_notif_created ON notifications(created_at)'); + console.log('[DB] Table notifications créée'); + } +} + +// ── Tickets support ─────────────────────────────────────────────────────────── +{ + const cols = db.prepare("PRAGMA table_info(tickets)").all().map(c => c.name); + if (!cols.includes('id')) { + db.exec(` + CREATE TABLE IF NOT EXISTS tickets ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + ticket_number TEXT NOT NULL UNIQUE, + subject TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'open', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ) + `); + db.exec('CREATE INDEX IF NOT EXISTS idx_tickets_user_id ON tickets(user_id)'); + db.exec('CREATE INDEX IF NOT EXISTS idx_tickets_status ON tickets(status)'); + db.exec('CREATE INDEX IF NOT EXISTS idx_tickets_updated ON tickets(updated_at)'); + console.log('[DB] Table tickets créée'); + } +} + +{ + const cols = db.prepare("PRAGMA table_info(ticket_messages)").all().map(c => c.name); + if (!cols.includes('id')) { + db.exec(` + CREATE TABLE IF NOT EXISTS ticket_messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ticket_id INTEGER NOT NULL REFERENCES tickets(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + body TEXT NOT NULL, + is_admin INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ) + `); + db.exec('CREATE INDEX IF NOT EXISTS idx_tmsg_ticket_id ON ticket_messages(ticket_id)'); + db.exec('CREATE INDEX IF NOT EXISTS idx_tmsg_created ON ticket_messages(created_at)'); + console.log('[DB] Table ticket_messages créée'); + } +} + +{ + const cols = db.prepare("PRAGMA table_info(ticket_attachments)").all().map(c => c.name); + if (!cols.includes('id')) { + db.exec(` + CREATE TABLE IF NOT EXISTS ticket_attachments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + message_id INTEGER NOT NULL REFERENCES ticket_messages(id) ON DELETE CASCADE, + filename TEXT NOT NULL, + original_name TEXT NOT NULL, + size INTEGER NOT NULL DEFAULT 0, + mime_type TEXT + ) + `); + db.exec('CREATE INDEX IF NOT EXISTS idx_tattach_message_id ON ticket_attachments(message_id)'); + console.log('[DB] Table ticket_attachments créée'); + } +} + + +{ + // Migration : colonne updated_at sur ticket_messages (édition 5 min) + const cols = db.prepare("PRAGMA table_info(ticket_messages)").all().map(c => c.name); + if (!cols.includes('updated_at')) { + db.exec("ALTER TABLE ticket_messages ADD COLUMN updated_at TEXT"); + console.log('[DB] ticket_messages: colonne updated_at ajoutée'); + } +} + +{ + // Migration : ticket_type et ticket_pages sur tickets (qualification) + const cols = db.prepare("PRAGMA table_info(tickets)").all().map(c => c.name); + if (!cols.includes('ticket_type')) { + db.exec("ALTER TABLE tickets ADD COLUMN ticket_type TEXT"); + console.log('[DB] tickets: colonne ticket_type ajoutée'); + } + if (!cols.includes('ticket_pages')) { + db.exec("ALTER TABLE tickets ADD COLUMN ticket_pages TEXT"); + console.log('[DB] tickets: colonne ticket_pages ajoutée'); + } +} + export default db; diff --git a/backend/src/routes/notifications.js b/backend/src/routes/notifications.js new file mode 100644 index 0000000..162355c --- /dev/null +++ b/backend/src/routes/notifications.js @@ -0,0 +1,137 @@ +import { Router } from 'express'; +import db from '../db/index.js'; +import { requireAuth, requireAdmin } from '../middleware/auth.js'; + +const router = Router(); +router.use(requireAuth); + +// ── POST /api/notifications/seed (admin only) ───────────────────────────────── +const SEED_NOTIFS = [ + { type: 'system', title: 'Mise à jour système', body: "Une nouvelle version de l'application est disponible." }, + { type: 'info', title: 'Conseil du jour', body: 'Diversifiez vos plateformes pour réduire le risque.' }, + { type: 'success', title: 'Investissement remboursé', body: 'Le prêt #1042 a été remboursé intégralement.' }, + { type: 'warning', title: 'Retard de paiement détecté', body: 'La plateforme Lendosphere signale un retard sur le prêt #0987.' }, + { type: 'ticket_reply', title: 'Réponse à votre ticket', body: "Votre demande #42 a reçu une réponse de l'équipe support." }, + { type: 'team', title: 'Nouvel investisseur', body: 'Un nouveau profil investisseur a été ajouté à votre espace.' }, + { type: 'security', title: 'Connexion depuis un nouvel appareil', body: "Une connexion a été détectée depuis un navigateur inconnu. Si ce n'est pas vous, changez votre mot de passe." }, + { type: 'announcement', title: 'Nouvelle fonctionnalité disponible', body: 'La page Fiscalité a été enrichie avec le simulateur CERFA 2561. Découvrez les nouveautés.' }, +]; + +router.post('/seed', requireAdmin, (req, res) => { + const userId = req.user.id; + const { count = 3 } = req.body ?? {}; + const n = Math.min(Math.max(1, Number(count)), SEED_NOTIFS.length); + const shuffled = [...SEED_NOTIFS].sort(() => Math.random() - .5).slice(0, n); + const insert = db.prepare( + 'INSERT INTO notifications (user_id, type, title, body) VALUES (?, ?, ?, ?)' + ); + const insertMany = db.transaction((items) => { + for (const item of items) insert.run(userId, item.type, item.title, item.body); + }); + insertMany(shuffled); + res.json({ ok: true, created: n }); +}); + +// ── POST /api/notifications/broadcast (admin only) ──────────────────────────── +// Envoyer une notification manuelle à tous les users ou un user spécifique +router.post('/broadcast', requireAdmin, (req, res) => { + const { type = 'announcement', title, body, link, user_id } = req.body ?? {}; + if (!title?.trim()) return res.status(400).json({ error: 'Titre requis' }); + + const VALID_TYPES = ['system','ticket_reply','info','team','success','warning','security','announcement']; + if (!VALID_TYPES.includes(type)) return res.status(400).json({ error: 'Type invalide' }); + + const insert = db.prepare( + 'INSERT INTO notifications (user_id, type, title, body, link) VALUES (?, ?, ?, ?, ?)' + ); + + let targets; + if (user_id) { + const u = db.prepare('SELECT id FROM users WHERE id = ?').get(user_id); + if (!u) return res.status(404).json({ error: 'Utilisateur introuvable' }); + targets = [u]; + } else { + targets = db.prepare('SELECT id FROM users').all(); + } + + const tx = db.transaction((users) => { + for (const u of users) insert.run(u.id, type, title.trim(), body?.trim() ?? null, link ?? null); + }); + tx(targets); + + res.json({ ok: true, sent: targets.length }); +}); + +// ── GET /api/notifications ─────────────────────────────────────────────────── +// Query params: ?unread_only=true&limit=50&offset=0&type=system +router.get('/', (req, res) => { + const userId = req.user.id; + const { unread_only, type, limit = 50, offset = 0 } = req.query; + + let where = 'WHERE n.user_id = ?'; + const params = [userId]; + + if (unread_only === 'true') { + where += ' AND n.read = 0'; + } + if (type) { + where += ' AND n.type = ?'; + params.push(type); + } + + const rows = db.prepare(` + SELECT * FROM notifications n + ${where} + ORDER BY n.created_at DESC + LIMIT ? OFFSET ? + `).all(...params, Number(limit), Number(offset)); + + const total = db.prepare(` + SELECT COUNT(*) as cnt FROM notifications n ${where} + `).get(...params).cnt; + + res.json({ notifications: rows, total }); +}); + +// ── GET /api/notifications/count ───────────────────────────────────────────── +router.get('/count', (req, res) => { + const { count } = db.prepare( + 'SELECT COUNT(*) as count FROM notifications WHERE user_id = ? AND read = 0' + ).get(req.user.id); + res.json({ unread: count }); +}); + +// ── PATCH /api/notifications/read-all ──────────────────────────────────────── +router.patch('/read-all', (req, res) => { + db.prepare('UPDATE notifications SET read = 1 WHERE user_id = ? AND read = 0') + .run(req.user.id); + res.json({ ok: true }); +}); + +// ── PATCH /api/notifications/:id/read ──────────────────────────────────────── +router.patch('/:id/read', (req, res) => { + const notif = db.prepare('SELECT id FROM notifications WHERE id = ? AND user_id = ?') + .get(req.params.id, req.user.id); + if (!notif) return res.status(404).json({ error: 'Notification introuvable' }); + + db.prepare('UPDATE notifications SET read = 1 WHERE id = ?').run(req.params.id); + res.json({ ok: true }); +}); + +// ── DELETE /api/notifications/:id ──────────────────────────────────────────── +router.delete('/:id', (req, res) => { + const notif = db.prepare('SELECT id FROM notifications WHERE id = ? AND user_id = ?') + .get(req.params.id, req.user.id); + if (!notif) return res.status(404).json({ error: 'Notification introuvable' }); + + db.prepare('DELETE FROM notifications WHERE id = ?').run(req.params.id); + res.json({ ok: true }); +}); + +// ── DELETE /api/notifications (tout supprimer) ──────────────────────────────── +router.delete('/', (req, res) => { + db.prepare('DELETE FROM notifications WHERE user_id = ?').run(req.user.id); + res.json({ ok: true }); +}); + +export default router; diff --git a/backend/src/routes/tickets.js b/backend/src/routes/tickets.js new file mode 100644 index 0000000..c02c44a --- /dev/null +++ b/backend/src/routes/tickets.js @@ -0,0 +1,281 @@ +import { Router } from 'express'; +import multer from 'multer'; +import path from 'node:path'; +import fs from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import db from '../db/index.js'; +import { requireAuth, requireAdmin } from '../middleware/auth.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +// ── Stockage fichiers ────────────────────────────────────────────────────── +const dataDir = process.env.DATA_DIR + ? path.resolve(process.env.DATA_DIR) + : path.resolve(__dirname, '../../../data'); +const attachDir = path.join(dataDir, 'attachments'); +fs.mkdirSync(attachDir, { recursive: true }); + +const storage = multer.diskStorage({ + destination: (_, __, cb) => cb(null, attachDir), + filename: (_, file, cb) => { + const ext = path.extname(file.originalname); + const base = `${Date.now()}-${Math.random().toString(36).slice(2)}`; + cb(null, base + ext); + }, +}); +const upload = multer({ storage, limits: { fileSize: 20 * 1024 * 1024 } }); // 20 Mo max + +const router = Router(); +router.use(requireAuth); + +// ── Helpers ──────────────────────────────────────────────────────────────── +function generateTicketNumber() { + const year = new Date().getFullYear(); + const last = db.prepare( + "SELECT ticket_number FROM tickets WHERE ticket_number LIKE ? ORDER BY id DESC LIMIT 1" + ).get(`TK-${year}-%`); + const seq = last ? parseInt(last.ticket_number.split('-')[2], 10) + 1 : 1; + return `TK-${year}-${String(seq).padStart(4, '0')}`; +} + +function notifyAdmins(title, body, link) { + const admins = db.prepare("SELECT id FROM users WHERE role = 'admin'").all(); + const insert = db.prepare( + 'INSERT INTO notifications (user_id, type, title, body, link) VALUES (?, ?, ?, ?, ?)' + ); + const tx = db.transaction((items) => { + for (const admin of items) insert.run(admin.id, 'ticket_reply', title, body, link); + }); + tx(admins); +} + +function notifyUser(userId, title, body, link) { + db.prepare( + 'INSERT INTO notifications (user_id, type, title, body, link) VALUES (?, ?, ?, ?, ?)' + ).run(userId, 'ticket_reply', title, body, link); +} + +function attachmentsForMessage(messageId) { + return db.prepare( + 'SELECT id, filename, original_name, size, mime_type FROM ticket_attachments WHERE message_id = ?' + ).all(messageId); +} + +// ── GET /api/tickets ───────────────────────────────────────────────────── +// Admin : tous les tickets. User : ses tickets uniquement. +router.get('/', (req, res) => { + const { id: userId, role } = req.user; + const isAdmin = role === 'admin'; + + const rows = isAdmin + ? db.prepare(` + SELECT t.*, u.display_name as user_name, u.email as user_email, + (SELECT COUNT(*) FROM ticket_messages WHERE ticket_id = t.id) as message_count, + (SELECT body FROM ticket_messages WHERE ticket_id = t.id ORDER BY created_at DESC LIMIT 1) as last_body, + (SELECT created_at FROM ticket_messages WHERE ticket_id = t.id ORDER BY created_at DESC LIMIT 1) as last_message_at + FROM tickets t + JOIN users u ON u.id = t.user_id + ORDER BY t.updated_at DESC + `).all() + : db.prepare(` + SELECT t.*, + (SELECT COUNT(*) FROM ticket_messages WHERE ticket_id = t.id) as message_count, + (SELECT body FROM ticket_messages WHERE ticket_id = t.id ORDER BY created_at DESC LIMIT 1) as last_body, + (SELECT created_at FROM ticket_messages WHERE ticket_id = t.id ORDER BY created_at DESC LIMIT 1) as last_message_at + FROM tickets t + WHERE t.user_id = ? + ORDER BY t.updated_at DESC + `).all(userId); + + res.json({ tickets: rows }); +}); + +// ── POST /api/tickets — créer un ticket ─────────────────────────────────── +router.post('/', upload.array('attachments', 10), (req, res) => { + const { id: userId } = req.user; + const { subject, body, ticket_type, ticket_pages } = req.body; + if (!subject?.trim() || !body?.trim()) { + return res.status(400).json({ error: 'Sujet et message requis' }); + } + + const ticketNumber = generateTicketNumber(); + + const tx = db.transaction(() => { + const ticketResult = db.prepare( + 'INSERT INTO tickets (user_id, ticket_number, subject, ticket_type, ticket_pages) VALUES (?, ?, ?, ?, ?)' + ).run(userId, ticketNumber, subject.trim(), ticket_type || null, ticket_pages || null); + const ticketId = ticketResult.lastInsertRowid; + + const msgResult = db.prepare( + 'INSERT INTO ticket_messages (ticket_id, user_id, body, is_admin) VALUES (?, ?, ?, 0)' + ).run(ticketId, userId, body.trim()); + const messageId = msgResult.lastInsertRowid; + + // Pièces jointes + if (req.files?.length) { + const insertAttach = db.prepare( + 'INSERT INTO ticket_attachments (message_id, filename, original_name, size, mime_type) VALUES (?, ?, ?, ?, ?)' + ); + for (const f of req.files) { + insertAttach.run(messageId, f.filename, f.originalname, f.size, f.mimetype); + } + } + + return { ticketId, ticketNumber }; + }); + + const { ticketId, ticketNumber: tn } = tx(); + + // Notifier les admins + const user = db.prepare('SELECT display_name as name FROM users WHERE id = ?').get(userId); + notifyAdmins( + `[${tn}] Nouveau ticket : ${subject.trim()}`, + `${user?.name ?? 'Utilisateur'} a ouvert un ticket de support.`, + `/communication?ticket=${ticketId}` + ); + + res.status(201).json({ ok: true, ticketId, ticketNumber: tn }); +}); + +// ── GET /api/tickets/:id — détail ticket + messages ─────────────────────── +router.get('/:id', (req, res) => { + const { id: userId, role } = req.user; + 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 + WHERE t.id = ? + `).get(req.params.id); + + if (!ticket) return res.status(404).json({ error: 'Ticket introuvable' }); + if (!isAdmin && ticket.user_id !== userId) return res.status(403).json({ error: 'Accès refusé' }); + + const messages = db.prepare(` + SELECT m.*, u.display_name as author_name, u.email as author_email + FROM ticket_messages m JOIN users u ON u.id = m.user_id + WHERE m.ticket_id = ? + ORDER BY m.created_at ASC + `).all(req.params.id); + + for (const msg of messages) { + msg.attachments = attachmentsForMessage(msg.id); + } + + res.json({ ticket, messages }); +}); + +// ── POST /api/tickets/:id/messages — répondre à un ticket ───────────────── +router.post('/:id/messages', upload.array('attachments', 10), (req, res) => { + const { id: userId, role } = req.user; + const isAdmin = role === 'admin'; + + const ticket = db.prepare('SELECT * FROM tickets WHERE id = ?').get(req.params.id); + if (!ticket) return res.status(404).json({ error: 'Ticket introuvable' }); + if (!isAdmin && ticket.user_id !== userId) return res.status(403).json({ error: 'Accès refusé' }); + if (ticket.status === 'resolved' && !isAdmin) return res.status(400).json({ error: 'Ticket résolu' }); + + const { body } = req.body; + if (!body?.trim()) return res.status(400).json({ error: 'Message vide' }); + + const tx = db.transaction(() => { + const msgResult = db.prepare( + 'INSERT INTO ticket_messages (ticket_id, user_id, body, is_admin) VALUES (?, ?, ?, ?)' + ).run(ticket.id, userId, body.trim(), isAdmin ? 1 : 0); + const messageId = msgResult.lastInsertRowid; + + if (req.files?.length) { + const insertAttach = db.prepare( + 'INSERT INTO ticket_attachments (message_id, filename, original_name, size, mime_type) VALUES (?, ?, ?, ?, ?)' + ); + for (const f of req.files) { + insertAttach.run(messageId, f.filename, f.originalname, f.size, f.mimetype); + } + } + + db.prepare("UPDATE tickets SET updated_at = datetime('now') WHERE id = ?").run(ticket.id); + return messageId; + }); + + tx(); + + // Notifications croisées + if (isAdmin) { + notifyUser( + ticket.user_id, + `[${ticket.ticket_number}] Réponse à votre ticket`, + `L'équipe support a répondu à votre ticket "${ticket.subject}".`, + `/communication?ticket=${ticket.id}` + ); + window?.dispatchEvent; // no-op server side + } else { + const user = db.prepare('SELECT display_name as name FROM users WHERE id = ?').get(userId); + notifyAdmins( + `[${ticket.ticket_number}] Nouvelle réponse`, + `${user?.name ?? 'Utilisateur'} a répondu sur le ticket "${ticket.subject}".`, + `/communication?ticket=${ticket.id}` + ); + } + + res.json({ ok: true }); +}); + +// ── 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' }); + + 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 status = ?, updated_at = datetime('now') WHERE id = ?") + .run(status, 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; + const { body } = req.body; + if (!body?.trim()) return res.status(400).json({ error: 'Message vide' }); + + const msg = db.prepare('SELECT * FROM ticket_messages WHERE id = ?').get(req.params.msgId); + if (!msg) return res.status(404).json({ error: 'Message introuvable' }); + + // Seul l'auteur peut modifier son propre message + if (msg.user_id !== userId) return res.status(403).json({ error: 'Accès refusé' }); + + // Vérifier la fenêtre de 5 minutes depuis created_at + const created = new Date(msg.created_at + 'Z'); + const ageSeconds = (Date.now() - created.getTime()) / 1000; + if (ageSeconds > 300) { + return res.status(403).json({ error: 'Délai de modification expiré (5 minutes)' }); + } + + db.prepare("UPDATE ticket_messages SET body = ?, updated_at = datetime('now') WHERE id = ?") + .run(body.trim(), msg.id); + + res.json({ ok: true }); +}); + +// ── GET /api/tickets/attachments/:filename — servir un fichier ──────────── +router.get('/attachments/:filename', (req, res) => { + const { id: userId, role } = req.user; + const isAdmin = role === 'admin'; + + // Vérifier que l'utilisateur a accès à cette pièce jointe + const attach = db.prepare(` + SELECT ta.*, tm.ticket_id FROM ticket_attachments ta + JOIN ticket_messages tm ON tm.id = ta.message_id + JOIN tickets t ON t.id = tm.ticket_id + WHERE ta.filename = ? AND (t.user_id = ? OR ? = 1) + `).get(req.params.filename, userId, isAdmin ? 1 : 0); + + if (!attach) return res.status(404).json({ error: 'Fichier introuvable' }); + + const filePath = path.join(attachDir, req.params.filename); + res.setHeader('Content-Disposition', `inline; filename="${attach.original_name}"`); + res.sendFile(filePath); +}); + +export default router; diff --git a/backend/src/server.js b/backend/src/server.js index 6708eb5..d94ccba 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -46,6 +46,8 @@ import refSecteursRouter from './routes/ref-secteurs.js'; import categoriesInvRouter from './routes/categories-inv.js'; import secteursInvRouter from './routes/secteurs-inv.js'; import associationsInvRouter from './routes/associations-inv.js'; +import notificationsRouter from './routes/notifications.js'; +import ticketsRouter from './routes/tickets.js'; import db from './db/index.js'; import { getSmtpConfig } from './utils/mailer.js'; @@ -130,6 +132,8 @@ app.use('/api/ref-secteurs', requireAuth, requireAdmin, refSecteursRouter); app.use('/api/categories-inv', requireAuth, categoriesInvRouter); app.use('/api/secteurs-inv', requireAuth, secteursInvRouter); app.use('/api', requireAuth, associationsInvRouter); +app.use('/api/notifications', notificationsRouter); +app.use('/api/tickets', requireAuth, ticketsRouter); app.use(errorHandler); diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 782235b..6a3fd29 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -23,6 +23,8 @@ import AdminFiscalite from './pages/AdminFiscalite.jsx'; import Aide from './pages/Aide.jsx'; import PlatformeProfile from './pages/PlatformeProfile.jsx'; import Plateformes from './pages/Plateformes.jsx'; +import Notifications from './pages/Notifications.jsx'; +import Communication from './pages/Communication.jsx'; function Protected({ children }) { const { token, loading } = useAuth(); @@ -73,6 +75,8 @@ export default function App() { } /> } /> } /> + } /> + } /> } /> } /> diff --git a/frontend/src/api.js b/frontend/src/api.js index 2db5436..ba51ade 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -58,6 +58,8 @@ export const api = { fetch(BASE + path, { method: 'DELETE', headers: authHeaders() }).then(handle), upload: (path, formData) => fetch(BASE + path, { method: 'POST', body: formData, headers: authHeaders() }).then(handle), + postForm: (path, formData) => + fetch(BASE + path, { method: 'POST', body: formData, headers: authHeaders() }).then(handle), blob: (path) => fetch(BASE + path, { headers: authHeaders() }).then(async res => { if (!res.ok) { const t = await res.text(); throw new Error(t || res.statusText); } diff --git a/frontend/src/components/Layout.jsx b/frontend/src/components/Layout.jsx index 537394b..258f949 100644 --- a/frontend/src/components/Layout.jsx +++ b/frontend/src/components/Layout.jsx @@ -5,6 +5,7 @@ import { useInvestisseur } from '../context/InvestisseurContext.jsx'; import { useUi } from '../context/UiContext.jsx'; import Logo from './Logo.jsx'; import UserMenu from './UserMenu.jsx'; +import NotificationBell from './NotificationBell.jsx'; /* ── Icônes nav ─────────────────────────────────────────────── */ const ICONS_BASE = '/api/icons-files/'; @@ -406,6 +407,8 @@ export default function Layout() { Net + + diff --git a/frontend/src/components/NotifTypeAvatar.jsx b/frontend/src/components/NotifTypeAvatar.jsx new file mode 100644 index 0000000..ef4c175 --- /dev/null +++ b/frontend/src/components/NotifTypeAvatar.jsx @@ -0,0 +1,97 @@ +// Icônes : paths Lucide (Radio, FileText, Info, Users, Check, AlertTriangle, ShieldAlert, Megaphone) + +export const TYPE_META = { + system: { label: 'Système', color: '#6b7280', bg: '#f3f4f6' }, + ticket_reply: { label: 'Ticket', color: '#3b82f6', bg: '#eff6ff' }, + info: { label: 'Info', color: '#0ea5e9', bg: '#f0f9ff' }, + team: { label: 'Équipe', color: '#8b5cf6', bg: '#f5f3ff' }, + success: { label: 'Succès', color: '#10b981', bg: '#ecfdf5' }, + warning: { label: 'Avertissement', color: '#f59e0b', bg: '#fffbeb' }, + security: { label: 'Sécurité', color: '#ef4444', bg: '#fef2f2' }, + announcement: { label: 'Annonce', color: '#ec4899', bg: '#fdf2f8' }, +}; + +const ICONS = { + // Lucide: Radio + system: (s) => ( + + + + + ), + // Lucide: FileText + ticket_reply: (s) => ( + + + + + + + ), + // Lucide: Info + info: (s) => ( + + + + + + ), + // Lucide: Users + team: (s) => ( + + + + + + + ), + // Lucide: Check + success: (s) => ( + + + + ), + // Lucide: AlertTriangle + warning: (s) => ( + + + + + + ), + // Lucide: ShieldAlert + security: (s) => ( + + + + + + ), + // Lucide: Megaphone + announcement: (s) => ( + + + + ), +}; + +const FALLBACK = (s) => ( + + + +); + +export default function NotifTypeAvatar({ type, size = 42 }) { + const meta = TYPE_META[type] ?? TYPE_META.info; + const iconSize = Math.round(size * 0.43); + const renderIcon = ICONS[type] ?? FALLBACK; + return ( +
+ {renderIcon(iconSize)} +
+ ); +} diff --git a/frontend/src/components/NotificationBell.jsx b/frontend/src/components/NotificationBell.jsx new file mode 100644 index 0000000..0a6d148 --- /dev/null +++ b/frontend/src/components/NotificationBell.jsx @@ -0,0 +1,177 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { useNavigate } from 'react-router-dom'; +import { api } from '../api.js'; +import NotifTypeAvatar from './NotifTypeAvatar.jsx'; + +const POLL_INTERVAL = 30_000; // 30s + +function timeAgo(dateStr) { + const diff = (Date.now() - new Date(dateStr + 'Z').getTime()) / 1000; + if (diff < 60) return 'À l\'instant'; + if (diff < 3600) return `Il y a ${Math.floor(diff / 60)} min`; + if (diff < 86400) return `Il y a ${Math.floor(diff / 3600)} h`; + if (diff < 86400 * 7) return `Il y a ${Math.floor(diff / 86400)} j`; + return new Date(dateStr + 'Z').toLocaleDateString('fr-FR', { day: '2-digit', month: 'short' }); +} + +export default function NotificationBell() { + const [unread, setUnread] = useState(0); + const [open, setOpen] = useState(false); + const [notifs, setNotifs] = useState([]); + const [loading, setLoading] = useState(false); + const [dropPos, setDropPos] = useState({ top: 0, right: 0 }); + const bellRef = useRef(null); + const dropRef = useRef(null); + const navigate = useNavigate(); + + // ── Polling unread count ──────────────────────────────────────────────── + const fetchCount = useCallback(async () => { + try { + const data = await api.get('/notifications/count'); + setUnread(data.unread ?? 0); + } catch { /* silencieux */ } + }, []); + + useEffect(() => { + fetchCount(); + const id = setInterval(fetchCount, POLL_INTERVAL); + // Rafraîchissement immédiat sur demande (ex: après un seed) + window.addEventListener('notif:refresh', fetchCount); + return () => { + clearInterval(id); + window.removeEventListener('notif:refresh', fetchCount); + }; + }, [fetchCount]); + + // ── Charger les notifs dans le dropdown ────────────────────────────────── + const fetchNotifs = useCallback(async () => { + setLoading(true); + try { + const data = await api.get('/notifications', { limit: 10 }); + setNotifs(data.notifications ?? []); + } catch { /* silencieux */ } + setLoading(false); + }, []); + + const handleOpen = () => { + if (open) { setOpen(false); return; } + const rect = bellRef.current?.getBoundingClientRect(); + if (rect) { + setDropPos({ + top: rect.bottom + 8, + right: window.innerWidth - rect.right, + }); + } + setOpen(true); + fetchNotifs(); + }; + + // ── Fermer au clic extérieur ───────────────────────────────────────────── + useEffect(() => { + if (!open) return; + const handler = (e) => { + if ( + !bellRef.current?.contains(e.target) && + !dropRef.current?.contains(e.target) + ) setOpen(false); + }; + document.addEventListener('mousedown', handler); + return () => document.removeEventListener('mousedown', handler); + }, [open]); + + // ── Actions ────────────────────────────────────────────────────────────── + const markRead = async (id) => { + try { + await api.patch(`/notifications/${id}/read`); + setNotifs(prev => prev.map(n => n.id === id ? { ...n, read: 1 } : n)); + setUnread(prev => Math.max(0, prev - 1)); + } catch { /* silencieux */ } + }; + + const markAll = async () => { + try { + await api.patch('/notifications/read-all'); + setNotifs(prev => prev.map(n => ({ ...n, read: 1 }))); + setUnread(0); + } catch { /* silencieux */ } + }; + + const handleNotifClick = (n) => { + if (!n.read) markRead(n.id); + if (n.link) { setOpen(false); navigate(n.link); } + }; + + // ── Dropdown ───────────────────────────────────────────────────────────── + const dropdown = open && createPortal( +
+
+ Notifications + {unread > 0 && ( + + )} +
+ +
+ {loading && ( +
Chargement…
+ )} + {!loading && notifs.length === 0 && ( +
Aucune notification
+ )} + {!loading && notifs.map(n => ( +
handleNotifClick(n)} + > + +
+
{n.title}
+ {n.body &&
{n.body}
} +
{timeAgo(n.created_at)}
+
+ {!n.read && } +
+ ))} +
+ +
+ +
+
, + document.body + ); + + return ( + <> + + {dropdown} + + ); +} diff --git a/frontend/src/components/UserMenu.jsx b/frontend/src/components/UserMenu.jsx index 1544e68..9feac4f 100644 --- a/frontend/src/components/UserMenu.jsx +++ b/frontend/src/components/UserMenu.jsx @@ -22,6 +22,9 @@ function IconSettings() { function IconAide() { return ; } +function IconComm() { + return ; +} function IconChevronRight() { return ; } @@ -259,6 +262,9 @@ export default function UserMenu() { + diff --git a/frontend/src/pages/Communication.jsx b/frontend/src/pages/Communication.jsx new file mode 100644 index 0000000..c9c7de9 --- /dev/null +++ b/frontend/src/pages/Communication.jsx @@ -0,0 +1,1378 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useSearchParams, useNavigate } from 'react-router-dom'; +import { api } from '../api.js'; +import { useAuth } from '../context/AuthContext.jsx'; +import NotifTypeAvatar, { TYPE_META } from '../components/NotifTypeAvatar.jsx'; +import { fmtDate } from '../utils/format.js'; + +// ── Helpers ──────────────────────────────────────────────────────────────── +function timeAgo(dateStr) { + if (!dateStr) return ''; + const diff = (Date.now() - new Date(dateStr + 'Z').getTime()) / 1000; + if (diff < 60) return 'À l\'instant'; + if (diff < 3600) return `Il y a ${Math.floor(diff / 60)} min`; + if (diff < 86400) return `Il y a ${Math.floor(diff / 3600)} h`; + if (diff < 86400 * 7) return `Il y a ${Math.floor(diff / 86400)} j`; + return new Date(dateStr + 'Z').toLocaleDateString('fr-FR', { day: '2-digit', month: 'short', year: 'numeric' }); +} + +function fmtSize(bytes) { + if (bytes < 1024) return `${bytes} o`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} Ko`; + return `${(bytes / 1024 / 1024).toFixed(1)} Mo`; +} + +function initials(name) { + if (!name) return '?'; + return name.split(' ').map(w => w[0]).join('').toUpperCase().slice(0, 2); +} + +// ── Avatar lettres ───────────────────────────────────────────────────────── +function UserAvatar({ name, isAdmin, size = 38 }) { + const bg = isAdmin ? 'var(--primary)' : '#64748b'; + return ( +
+ {initials(name)} +
+ ); +} + +// ── Badge statut ticket ──────────────────────────────────────────────────── +function StatusBadge({ status }) { + const styles = { + open: { bg: '#dcfce7', color: '#16a34a', label: 'Ouvert' }, + resolved: { bg: '#f1f5f9', color: '#64748b', label: 'Résolu' }, + }; + const s = styles[status] ?? styles.open; + return ( + {s.label} + ); +} + +// ── Icône fichier selon MIME ─────────────────────────────────────────────── +function FileIcon({ mime }) { + if (mime?.startsWith('image/')) return '🖼'; + if (mime === 'application/pdf') return '📄'; + if (mime?.includes('zip') || mime?.includes('compressed')) return '🗜'; + if (mime?.includes('spreadsheet') || mime?.includes('excel')) return '📊'; + return '📎'; +} + +// ── Chip fichier avec aperçu image ──────────────────────────────────────── +function FileChip({ file, onRemove }) { + const isImage = file.type.startsWith('image/'); + const [url, setUrl] = useState(null); + useEffect(() => { + if (!isImage) return; + const objUrl = URL.createObjectURL(file); + setUrl(objUrl); + return () => URL.revokeObjectURL(objUrl); + }, [file, isImage]); + return ( + + {isImage && url + ? {file.name} + : + } + {file.name} + + + ); +} + +// ── Gestion du coller image ─────────────────────────────────────────────── +function onPasteImage(setter) { + return (e) => { + const items = Array.from(e.clipboardData?.items ?? []); + const images = items + .filter(item => item.type.startsWith('image/')) + .map(item => item.getAsFile()) + .filter(Boolean) + .map((f, i) => { + const ext = f.type.split('/')[1]?.replace('jpeg', 'jpg') || 'png'; + return new File([f], `image-collée-${Date.now()}${i ? '-' + i : ''}.${ext}`, { type: f.type }); + }); + if (images.length) { + setter(prev => [...prev, ...images]); + } + }; +} + +// ── Helper nom téléchargement ───────────────────────────────────────────── +function buildDownloadName(ticketNumber, msgDate, originalName) { + const d = msgDate ? new Date(msgDate.endsWith('Z') ? msgDate : msgDate + 'Z') : new Date(); + const yyyy = d.getFullYear(); + const mm = String(d.getMonth() + 1).padStart(2, '0'); + const dd = String(d.getDate()).padStart(2, '0'); + const hh = String(d.getHours()).padStart(2, '0'); + const mn = String(d.getMinutes()).padStart(2, '0'); + const ext = (originalName.match(/\.[^.]+$/) ?? [''])[0]; + const base = originalName.replace(/\.[^.]+$/, '').replace(/[^\w\-]/g, '_').slice(0, 40); + return `${ticketNumber}_${yyyy}-${mm}-${dd}_${hh}h${mn}_${base}${ext}`; +} + +// ── Image jointe dans le thread ─────────────────────────────────────────── +function AttachmentImage({ filename, originalName, size, onExpand, ticketNumber, msgDate }) { + const [url, setUrl] = useState(null); + useEffect(() => { + let objUrl; + api.blob(`/tickets/attachments/${filename}`) + .then(blob => { + objUrl = URL.createObjectURL(blob); + setUrl(objUrl); + }).catch(() => {}); + return () => { if (objUrl) URL.revokeObjectURL(objUrl); }; + }, [filename]); + + const handleDownload = () => { + if (!url) return; + const a = document.createElement('a'); + a.href = url; a.download = buildDownloadName(ticketNumber || 'TK', msgDate, originalName); a.click(); + }; + + return ( +
+ {url + ? {originalName} + :
🖼
+ } + {url && ( +
+ + +
+ )} +
{originalName} · {fmtSize(size)}
+
+ ); +} + +// ── Constantes tags tickets ─────────────────────────────────────────────── +const TICKET_TYPES = [ + { value: 'bug_bloquant', label: 'Bug bloquant' }, + { value: 'bug_non_bloquant', label: 'Bug non bloquant' }, + { value: 'amelioration', label: 'Amélioration' }, + { value: 'question', label: 'Question' }, +]; +const TICKET_PAGES = [ + { value: 'tableau_de_bord', label: 'Tableau de bord' }, + { value: 'plateformes', label: 'Plateformes' }, + { value: 'investissements', label: 'Investissements' }, + { value: 'depots_retraits', label: 'Dépôts / Retraits' }, + { value: 'fiscalite', label: 'Fiscalité' }, + { value: 'mon_compte', label: 'Mon compte' }, + { value: 'parametres', label: 'Paramètres' }, + { value: 'autres', label: 'Autres' }, +]; +const TYPE_COLORS = { + bug_bloquant: { background: '#fee2e2', color: '#b91c1c' }, + bug_non_bloquant: { background: '#ffedd5', color: '#c2410c' }, + amelioration: { background: '#dbeafe', color: '#1d4ed8' }, + question: { background: '#d1fae5', color: '#065f46' }, +}; + +// ── TagSelect ───────────────────────────────────────────────────────────── +function TagSelect({ label, options, value, onChange, multi = false }) { + const [open, setOpen] = useState(false); + const ref = useRef(null); + + useEffect(() => { + const h = e => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); }; + document.addEventListener('mousedown', h); + return () => document.removeEventListener('mousedown', h); + }, []); + + const isSelected = v => multi ? (value || []).includes(v) : value === v; + + const toggle = v => { + if (multi) { + const arr = value || []; + onChange(arr.includes(v) ? arr.filter(x => x !== v) : [...arr, v]); + } else { + onChange(value === v ? null : v); + setOpen(false); + } + }; + + const selectedCount = multi ? (value?.length || 0) : (value ? 1 : 0); + const headerLabel = multi + ? (selectedCount === 0 ? label : `${selectedCount} sélectionné${selectedCount > 1 ? 's' : ''}`) + : (value ? options.find(o => o.value === value)?.label ?? label : label); + + return ( +
+ + {open && ( +
+ {options.map(opt => ( + + ))} +
+ )} +
+ ); +} + +// ── TicketChips ─────────────────────────────────────────────────────────── +function TicketChips({ ticketType, ticketPages }) { + const type = TICKET_TYPES.find(t => t.value === ticketType); + const pages = ticketPages ? (Array.isArray(ticketPages) ? ticketPages : JSON.parse(ticketPages)) : []; + if (!type && pages.length === 0) return null; + return ( +
+ {type && ( + + {type.label} + + )} + {pages.map(p => { + const page = TICKET_PAGES.find(x => x.value === p); + return page ? {page.label} : null; + })} +
+ ); +} + +// ── MessageBody — rendu HTML avec overlay CSS sur images inline ──────────── +const ICON_EXPAND_SVG = ``; +const ICON_DOWNLOAD_SVG = ``; + +function MessageBody({ html, onExpand, ticketNumber, msgDate }) { + const ref = useRef(null); + + useEffect(() => { + const el = ref.current; + if (!el) return; + el.querySelectorAll('.msg-inline-img:not([data-hovered])').forEach(img => { + img.setAttribute('data-hovered', '1'); + const wrap = document.createElement('span'); + wrap.className = 'msg-img-wrap'; + img.parentNode.insertBefore(wrap, img); + wrap.appendChild(img); + const overlay = document.createElement('span'); + overlay.className = 'msg-img-hover-overlay'; + overlay.innerHTML = + `` + + ``; + wrap.appendChild(overlay); + }); + }, [html]); + + const handleClick = useCallback((e) => { + const btn = e.target.closest('[data-img-action]'); + if (btn) { + const img = btn.closest('.msg-img-wrap')?.querySelector('img'); + if (!img) return; + if (btn.dataset.imgAction === 'expand') { + onExpand(img.src, img.alt || 'image'); + } else { + const a = document.createElement('a'); + a.href = img.src; a.download = buildDownloadName(ticketNumber || 'TK', msgDate, img.alt || 'image.png'); a.click(); + } + return; + } + if (e.target.tagName === 'IMG') onExpand(e.target.src, e.target.alt || 'image'); + }, [onExpand]); + + return ( +
+ ); +} + +// ── Sanitisation HTML (affichage messages) ──────────────────────────────── +const ALLOWED_TAGS = new Set(['b','i','u','s','strong','em','ul','ol','li','a','br','p','div','span','img']); +function sanitizeHTML(html) { + if (!html) return ''; + const doc = new DOMParser().parseFromString(html, 'text/html'); + function clean(node) { + for (const child of [...node.childNodes]) { + if (child.nodeType === 3) continue; + if (child.nodeType === 1) { + const tag = child.tagName.toLowerCase(); + if (!ALLOWED_TAGS.has(tag)) { child.replaceWith(...child.childNodes); continue; } + if (tag === 'img') { + const src = child.getAttribute('src') ?? ''; + if (!src.startsWith('data:image/')) { child.remove(); continue; } + // Garde uniquement src et alt + for (const attr of [...child.attributes]) { + if (attr.name !== 'src' && attr.name !== 'alt') child.removeAttribute(attr.name); + } + child.setAttribute('class', 'msg-inline-img'); + continue; // pas d'enfants + } + for (const attr of [...child.attributes]) { + if (tag === 'a' && attr.name === 'href') continue; + child.removeAttribute(attr.name); + } + if (tag === 'a') { child.setAttribute('target','_blank'); child.setAttribute('rel','noreferrer'); } + clean(child); + } else { child.remove(); } + } + } + clean(doc.body); + return doc.body.innerHTML; +} + +// ── Nettoie l'HTML de l'éditeur avant envoi (retire les wrappers rte-img-wrap) ── +function stripPreview(html) { + if (!html) return ''; + return html.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 120); +} + +function stripEditorHTML(html) { + if (!html) return ''; + const div = document.createElement('div'); + div.innerHTML = html; + for (const wrap of [...div.querySelectorAll('.rte-img-wrap')]) { + const img = wrap.querySelector('img'); + if (img) wrap.replaceWith(img); else wrap.remove(); + } + return div.innerHTML; +} + +// ── Éditeur de texte riche (contenteditable) ─────────────────────────────── +function RichTextEditor({ value, onChange, onImgExpand, placeholder, minHeight = 100 }) { + const ref = useRef(null); + + useEffect(() => { + if (ref.current && (value === '' || value === null || value === undefined)) { + ref.current.innerHTML = ''; + } + }, [value]); + + const exec = (cmd, val = null) => { + // Ne redonner le focus que si l'éditeur ne l'a pas déjà + // (focus() détruirait la sélection active) + if (document.activeElement !== ref.current) ref.current?.focus(); + document.execCommand(cmd, false, val); + onChange(ref.current?.innerHTML ?? ''); + }; + + const insertLink = () => { + const url = window.prompt('URL du lien :'); + if (url?.trim()) exec('createLink', url.trim()); + }; + + // Coller une image → insertion inline au curseur avec overlay Agrandir/Supprimer + const handlePaste = (e) => { + const items = Array.from(e.clipboardData?.items ?? []); + const imageItems = items.filter(item => item.type.startsWith('image/')); + if (!imageItems.length) return; + e.preventDefault(); + imageItems.forEach(item => { + const file = item.getAsFile(); + if (!file) return; + const reader = new FileReader(); + reader.onload = ev => { + const dataUrl = ev.target.result; + const ICON_EXPAND = ``; + const ICON_DEL = ``; + const html = `` + + `image collée` + + `` + + `` + + `` + + `​`; + if (document.activeElement !== ref.current) ref.current?.focus(); + document.execCommand('insertHTML', false, html); + onChange(ref.current?.innerHTML ?? ''); + }; + reader.readAsDataURL(file); + }); + }; + + // Délégation de clics sur les boutons Agrandir/Supprimer dans l'éditeur + const handleEditorClick = (e) => { + const btn = e.target.closest('[data-rte-action]'); + if (!btn) return; + e.preventDefault(); + const action = btn.dataset.rteAction; + const wrap = btn.closest('.rte-img-wrap'); + if (action === 'remove' && wrap) { + wrap.remove(); + onChange(ref.current?.innerHTML ?? ''); + } else if (action === 'expand' && wrap) { + const img = wrap.querySelector('img'); + if (img) onImgExpand?.(img.src, 'image collée'); + } + }; + + return ( +
+
+ + + + +
+ +
+ + +
+
onChange(e.currentTarget.innerHTML)} + onPaste={handlePaste} + onClick={handleEditorClick} + data-placeholder={placeholder} + style={{ minHeight }} + /> +
+ ); +} + +// ── Sélecteur de type de notification ───────────────────────────────────── +function TypeSelector({ value, onChange }) { + return ( +
+ {Object.entries(TYPE_META).map(([k, m]) => ( + + ))} +
+ ); +} + +// ══════════════════════════════════════════════════════════════════════════ +// COMPOSANT PRINCIPAL +// ══════════════════════════════════════════════════════════════════════════ +export default function Communication() { + const { user, isAdmin } = useAuth(); + const [searchParams, setSearchParams] = useSearchParams(); + const navigate = useNavigate(); + + const [tab, setTab] = useState('support'); // 'support' | 'notifications' + const [showBroadcastForm, setShowBroadcastForm] = useState(false); + const [lightbox, setLightbox] = useState(null); // { url, name } + const [editingMsg, setEditingMsg] = useState(null); // { id, body } + const [editSaving, setEditSaving] = useState(false); + // Tick toutes les 10s pour rafraîchir les boutons "Modifier" (fenêtre 5 min) + const [now, setNow] = useState(Date.now()); + useEffect(() => { + const id = setInterval(() => setNow(Date.now()), 10_000); + return () => clearInterval(id); + }, []); + const [tickets, setTickets] = useState([]); + const [ticketSearch, setTicketSearch] = useState(''); + const [ticketFilter, setTicketFilter] = useState('all'); // 'all'|'open'|'resolved' + const [notifFilter, setNotifFilter] = useState('all'); // 'all'|'unread' + const [selectedTicket, setSelectedTicket] = useState(null); + const [thread, setThread] = useState(null); // { ticket, messages } + const [notifs, setNotifs] = useState([]); + const [notifPage, setNotifPage] = useState(0); + const [notifsTotal, setNotifsTotal] = useState(0); + const [selectedNotif, setSelectedNotif] = useState(null); + const [loading, setLoading] = useState(false); + + // ── Nouveau ticket ─────────────────────────────────────────────────── + const [showCompose, setShowCompose] = useState(false); + const [composeSubject, setComposeSubject] = useState(''); + const [composeBody, setComposeBody] = useState(''); + const [composeFiles, setComposeFiles] = useState([]); + const [composeType, setComposeType] = useState(null); + const [composePages, setComposePages] = useState([]); + const [composeSending, setComposeSending] = useState(''); + const fileInputRef = useRef(null); + + // ── Répondre ───────────────────────────────────────────────────────── + const [replyBody, setReplyBody] = useState(''); + const [replyFiles, setReplyFiles] = useState([]); + const replyFileRef = useRef(null); + const [replySending, setReplySending] = useState(false); + + // ── Broadcast (admin) ──────────────────────────────────────────────── + const [bcType, setBcType] = useState('announcement'); + const [bcTitle, setBcTitle] = useState(''); + const [bcBody, setBcBody] = useState(''); + const [bcUserId, setBcUserId] = useState(''); + const [bcSending, setBcSending] = useState(false); + const [bcResult, setBcResult] = useState(null); + const [users, setUsers] = useState([]); + + // ── Charger tickets ────────────────────────────────────────────────── + const fetchTickets = useCallback(async () => { + setLoading(true); + try { + const data = await api.get('/tickets'); + setTickets(data.tickets ?? []); + } catch { /* silencieux */ } + setLoading(false); + }, []); + + // ── Charger notifications ──────────────────────────────────────────── + const fetchNotifs = useCallback(async () => { + try { + const data = await api.get('/notifications', { limit: 50 }); + const list = data.notifications ?? []; + setNotifs(list); + setNotifsTotal(data.total ?? 0); + setNotifPage(0); + // Auto-sélectionner la première notif si aucune n'est sélectionnée + setSelectedNotif(prev => prev ?? (list.length > 0 ? list[0] : null)); + } catch { /* silencieux */ } + }, []); + + // ── Charger thread ─────────────────────────────────────────────────── + const fetchThread = useCallback(async (id) => { + try { + const data = await api.get(`/tickets/${id}`); + setThread(data); + } catch { /* silencieux */ } + }, []); + + // ── Charger users (admin) ──────────────────────────────────────────── + const fetchUsers = useCallback(async () => { + if (!isAdmin) return; + try { + const data = await api.get('/admin/users'); + setUsers(data.users ?? data ?? []); + } catch { /* silencieux */ } + }, [isAdmin]); + + useEffect(() => { + fetchTickets(); + fetchNotifs(); + if (isAdmin) fetchUsers(); + }, [fetchTickets, fetchNotifs, fetchUsers]); + + // ── Restaurer ticket depuis URL ────────────────────────────────────── + useEffect(() => { + const ticketId = searchParams.get('ticket'); + if (ticketId) { + setTab('support'); + setSelectedTicket(Number(ticketId)); + fetchThread(ticketId); + // Nettoyer le param URL sans recharger + setSearchParams({}, { replace: true }); + } + }, [searchParams]); // eslint-disable-line + + // ── Ouvrir un ticket ───────────────────────────────────────────────── + const openTicket = (id) => { + setSelectedTicket(id); + setSelectedNotif(null); + fetchThread(id); + setSearchParams({ ticket: id }); + setReplyBody(''); + setReplyFiles([]); + }; + + // ── Ouvrir une notification ────────────────────────────────────────── + const openNotif = async (n) => { + setSelectedNotif(n); + setSelectedTicket(null); + setShowBroadcastForm(false); + if (!n.read) { + try { + await api.patch(`/notifications/${n.id}/read`); + setNotifs(prev => prev.map(x => x.id === n.id ? { ...x, read: 1 } : x)); + window.dispatchEvent(new CustomEvent('notif:refresh')); + } catch { /* silencieux */ } + } + }; + + // ── Créer ticket ───────────────────────────────────────────────────── + const submitTicket = async (e) => { + e.preventDefault(); + if (!composeSubject.trim() || !composeBody.replace(/<[^>]*>/g,'').trim()) return; + setComposeSending('sending'); + try { + const fd = new FormData(); + fd.append('subject', composeSubject.trim()); + fd.append('body', stripEditorHTML(composeBody).trim()); + if (composeType) fd.append('ticket_type', composeType); + if (composePages.length) fd.append('ticket_pages', JSON.stringify(composePages)); + for (const f of composeFiles) fd.append('attachments', f); + const data = await api.postForm('/tickets', fd); + setShowCompose(false); + setComposeSubject(''); + setComposeBody(''); + setComposeType(null); + setComposePages([]); + setComposeFiles([]); + await fetchTickets(); + if (data.ticketId) openTicket(data.ticketId); + } catch { /* silencieux */ } + setComposeSending(''); + }; + + // ── Répondre ───────────────────────────────────────────────────────── + const submitReply = async (e) => { + e.preventDefault(); + if (!replyBody.trim() || !thread) return; + setReplySending(true); + try { + const fd = new FormData(); + fd.append('body', stripEditorHTML(replyBody).trim()); + for (const f of replyFiles) fd.append('attachments', f); + await api.postForm(`/tickets/${thread.ticket.id}/messages`, fd); + setReplyBody(''); + setReplyFiles([]); + await fetchThread(thread.ticket.id); + await fetchTickets(); + } catch { /* silencieux */ } + setReplySending(false); + }; + + // ── Résoudre / rouvrir ─────────────────────────────────────────────── + const toggleStatus = async () => { + if (!thread) return; + const newStatus = thread.ticket.status === 'open' ? 'resolved' : 'open'; + try { + await api.patch(`/tickets/${thread.ticket.id}/status`, { status: newStatus }); + await fetchThread(thread.ticket.id); + await fetchTickets(); + } catch { /* silencieux */ } + }; + + // ── Broadcast ──────────────────────────────────────────────────────── + const submitBroadcast = async (e) => { + e.preventDefault(); + if (!bcTitle.trim()) return; + setBcSending(true); + setBcResult(null); + try { + const payload = { type: bcType, title: bcTitle.trim(), body: bcBody.trim() || undefined }; + if (bcUserId) payload.user_id = Number(bcUserId); + const data = await api.post('/notifications/broadcast', payload); + setBcResult({ ok: true, msg: `Envoyé à ${data.sent} utilisateur${data.sent > 1 ? 's' : ''}` }); + setBcTitle(''); + setBcBody(''); + setBcUserId(''); + window.dispatchEvent(new CustomEvent('notif:refresh')); + await fetchNotifs(); // rafraîchir la liste locale immédiatement + setShowBroadcastForm(false); + } catch { + setBcResult({ ok: false, msg: 'Erreur lors de l\'envoi' }); + } + setBcSending(false); + }; + + // ── Marquer toutes notifs lues ─────────────────────────────────────── + const markAllRead = async () => { + try { + await api.patch('/notifications/read-all'); + setNotifs(prev => prev.map(n => ({ ...n, read: 1 }))); + window.dispatchEvent(new CustomEvent('notif:refresh')); + } catch { /* silencieux */ } + }; + + // ── Édition message (fenêtre 5 min) ──────────────────────────────────── + const EDIT_WINDOW_MS = 5 * 60 * 1000; + const canEditMsg = (msg) => + msg.user_id === user?.id && + (now - new Date(msg.created_at + 'Z').getTime()) < EDIT_WINDOW_MS; + + const fmtRemaining = (msg) => { + const ms = EDIT_WINDOW_MS - (now - new Date(msg.created_at + 'Z').getTime()); + if (ms <= 0) return null; + const mins = Math.ceil(ms / 60_000); + return mins <= 1 ? '< 1 min' : `${mins} min`; + }; + + const saveEdit = async (msg) => { + if (!editingMsg?.body?.replace(/<[^>]*>/g, '').trim()) return; + setEditSaving(true); + try { + await api.put(`/tickets/${msg.ticket_id}/messages/${msg.id}`, { body: editingMsg.body }); + // Mettre à jour localement sans recharger tout le thread + setThread(prev => ({ + ...prev, + messages: prev.messages.map(m => + m.id === msg.id ? { ...m, body: editingMsg.body, updated_at: new Date().toISOString() } : m + ), + })); + setEditingMsg(null); + } catch (e) { + alert(e.message || 'Erreur lors de la sauvegarde'); + } + setEditSaving(false); + }; + + const unreadCount = notifs.filter(n => !n.read).length; + + // ──────────────────────────────────────────────────────────────────── + // RENDER + // ──────────────────────────────────────────────────────────────────── + return ( + <> +
+
+ {/* ── Topbar 3 colonnes ── */} +
+ {/* Colonne 1 — Titre page */} +
+ + Communication +
+ {/* Colonne 2 — Dossier + filtres */} +
+ {tab === 'support' ? 'Support' : 'Notifications'} +
+ {tab === 'support' && ( + <> + {[['all','Tous'],['open','Ouverts'],['resolved','Résolus']].map(([v,l]) => ( + + ))} + + )} + {tab === 'notifications' && ( + <> + {[['all','Tous'],['unread','Non lus']].map(([v,l]) => ( + + ))} + {unreadCount > 0 && ( + + )} + + )} +
+
+ {/* Colonne 3 — Toolbar contextuelle */} +
+ {tab === 'support' && thread && ( + <> + {thread.ticket.ticket_number} — {thread.ticket.subject} +
+ {isAdmin && ( + + )} +
+ + )} + {tab === 'notifications' && selectedNotif && ( +
+ {!selectedNotif.read && ( + + )} + {selectedNotif.link && ( + + )} +
+ )} +
+
+ {/* ── Corps 3 panneaux ── */} +
+ + {/* ── Sidebar gauche ── */} + + + {/* ── Liste centrale ── */} +
+ {tab === 'support' && ( + <> +
+ setTicketSearch(e.target.value)} + /> +
+
+ {loading &&
Chargement…
} + {!loading && tickets.length === 0 && ( +
+
💬
+
Aucun ticket
+
Créez votre premier ticket de support
+
+ )} + {tickets + .filter(t => { + if (!ticketSearch.trim()) return true; + const q = ticketSearch.toLowerCase(); + return ( + t.subject?.toLowerCase().includes(q) || + t.ticket_number?.toLowerCase().includes(q) || + t.user_name?.toLowerCase().includes(q) + ); + }) + .map(t => ( +
openTicket(t.id)} + > + +
+
+ {t.user_name ?? 'Moi'} + +
+
{t.ticket_number} — {t.subject}
+ + {t.last_body && ( +
{stripPreview(t.last_body)}
+ )} +
+ {t.message_count} message{t.message_count > 1 ? 's' : ''} + {timeAgo(t.last_message_at ?? t.created_at)} +
+
+
+ ))} +
+ + )} + + {tab === 'notifications' && (() => { + const NOTIF_PER_PAGE = 10; + const filteredNotifs = notifFilter === 'unread' ? notifs.filter(n => !n.read) : notifs; + 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); + const start = totalNotifs === 0 ? 0 : notifPage * NOTIF_PER_PAGE + 1; + const end = Math.min((notifPage + 1) * NOTIF_PER_PAGE, totalNotifs); + return ( + <> +
+ Notifications +
+
+ {notifs.length === 0 && ( +
+
🔔
+
Aucune notification
+
+ )} + {pagedNotifs.map(n => ( +
openNotif(n)} + > + +
+
+ {n.title} + + {(TYPE_META[n.type] ?? TYPE_META.info).label} + +
+ {n.body &&
{stripPreview(n.body)}
} +
+ {timeAgo(n.created_at)} +
+
+ {!n.read && } +
+ ))} +
+ {totalNotifs > NOTIF_PER_PAGE && ( +
+ + {start}–{end} sur {totalNotifs} + +
+ + +
+
+ )} + + ); + })()} +
+ + {/* ── Panneau détail droite ── */} +
+ + {/* Détail ticket */} + {tab === 'support' && thread && ( + <> +
+
+
+ {thread.ticket.ticket_number} — {thread.ticket.subject} +
+
+ Ouvert par {thread.ticket.user_name} · {fmtDate(thread.ticket.created_at)} +
+ +
+
+ +
+
+ +
+ {thread.messages.map(msg => ( +
+ +
+
+ {msg.author_name} + {msg.is_admin && ( + Support + )} + + {msg.updated_at && ( + modifié + )} + {timeAgo(msg.created_at)} + {canEditMsg(msg) && editingMsg?.id !== msg.id && ( + + )} + +
+ {editingMsg?.id === msg.id ? ( +
+ setEditingMsg(e => ({ ...e, body }))} + placeholder="Modifiez votre message…" + minHeight={80} + /> +
+ + ⏱ {fmtRemaining(msg) ?? 'Délai expiré'} + + + +
+
+ ) : ( + setLightbox({ url: src, name })} + ticketNumber={thread.ticket.ticket_number} + msgDate={msg.created_at} + /> + )} + {msg.attachments?.length > 0 && (() => { + const images = msg.attachments.filter(a => a.mime_type?.startsWith('image/')); + const files = msg.attachments.filter(a => !a.mime_type?.startsWith('image/')); + return ( + <> + {images.length > 0 && ( +
+ {images.map(a => ( + setLightbox({ url, name })} + ticketNumber={thread.ticket.ticket_number} + msgDate={msg.created_at} + /> + ))} +
+ )} + {files.length > 0 && ( +
+ {files.map(a => ( + + + {a.original_name} + {fmtSize(a.size)} + + ))} +
+ )} + + ); + })()} +
+
+ ))} +
+ + {/* Zone réponse */} + {(thread.ticket.status === 'open' || isAdmin) && ( +
+ setLightbox({ url, name })} + placeholder="Votre réponse… (Ctrl+V pour coller une image)" + minHeight={90} + /> + {replyFiles.length > 0 && ( +
+ {replyFiles.map((f, i) => ( + setReplyFiles(prev => prev.filter((_, j) => j !== i))} /> + ))} +
+ )} +
+ + setReplyFiles(prev => [...prev, ...Array.from(e.target.files)])} + /> + +
+ + )} + {thread.ticket.status === 'resolved' && !isAdmin && ( +
+ Ce ticket est résolu. Créez un nouveau ticket si besoin. +
+ )} + + )} + + {/* Détail notification */} + {tab === 'notifications' && selectedNotif && ( +
+
+ +
+
{selectedNotif.title}
+
+ {timeAgo(selectedNotif.created_at)} · {(TYPE_META[selectedNotif.type] ?? TYPE_META.info).label} +
+
+
+ {selectedNotif.body && ( +
+ {selectedNotif.body} +
+ )} + {selectedNotif.link && ( +
+ +
+ )} +
+ )} + + {/* Bloc broadcast admin (onglet notifications) */} + {tab === 'notifications' && isAdmin && showBroadcastForm && ( +
+
Envoyer une notification
+
+
+ + +
+
+ + +
+
+ + setBcTitle(e.target.value)} + placeholder="Titre de la notification" + required + /> +
+
+ +