Centre de notification
This commit is contained in:
@@ -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;
|
export default db;
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -46,6 +46,8 @@ import refSecteursRouter from './routes/ref-secteurs.js';
|
|||||||
import categoriesInvRouter from './routes/categories-inv.js';
|
import categoriesInvRouter from './routes/categories-inv.js';
|
||||||
import secteursInvRouter from './routes/secteurs-inv.js';
|
import secteursInvRouter from './routes/secteurs-inv.js';
|
||||||
import associationsInvRouter from './routes/associations-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 db from './db/index.js';
|
||||||
import { getSmtpConfig } from './utils/mailer.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/categories-inv', requireAuth, categoriesInvRouter);
|
||||||
app.use('/api/secteurs-inv', requireAuth, secteursInvRouter);
|
app.use('/api/secteurs-inv', requireAuth, secteursInvRouter);
|
||||||
app.use('/api', requireAuth, associationsInvRouter);
|
app.use('/api', requireAuth, associationsInvRouter);
|
||||||
|
app.use('/api/notifications', notificationsRouter);
|
||||||
|
app.use('/api/tickets', requireAuth, ticketsRouter);
|
||||||
|
|
||||||
app.use(errorHandler);
|
app.use(errorHandler);
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ import AdminFiscalite from './pages/AdminFiscalite.jsx';
|
|||||||
import Aide from './pages/Aide.jsx';
|
import Aide from './pages/Aide.jsx';
|
||||||
import PlatformeProfile from './pages/PlatformeProfile.jsx';
|
import PlatformeProfile from './pages/PlatformeProfile.jsx';
|
||||||
import Plateformes from './pages/Plateformes.jsx';
|
import Plateformes from './pages/Plateformes.jsx';
|
||||||
|
import Notifications from './pages/Notifications.jsx';
|
||||||
|
import Communication from './pages/Communication.jsx';
|
||||||
|
|
||||||
function Protected({ children }) {
|
function Protected({ children }) {
|
||||||
const { token, loading } = useAuth();
|
const { token, loading } = useAuth();
|
||||||
@@ -73,6 +75,8 @@ export default function App() {
|
|||||||
<Route path="admin/plateformes" element={<AdminOnly><AdminPlateformes /></AdminOnly>} />
|
<Route path="admin/plateformes" element={<AdminOnly><AdminPlateformes /></AdminOnly>} />
|
||||||
<Route path="admin/fiscalite" element={<AdminOnly><AdminFiscalite /></AdminOnly>} />
|
<Route path="admin/fiscalite" element={<AdminOnly><AdminFiscalite /></AdminOnly>} />
|
||||||
<Route path="aide" element={<Aide />} />
|
<Route path="aide" element={<Aide />} />
|
||||||
|
<Route path="notifications" element={<Notifications />} />
|
||||||
|
<Route path="communication" element={<Communication />} />
|
||||||
<Route path="referentiel/:id" element={<PlatformeProfile />} />
|
<Route path="referentiel/:id" element={<PlatformeProfile />} />
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|||||||
@@ -58,6 +58,8 @@ export const api = {
|
|||||||
fetch(BASE + path, { method: 'DELETE', headers: authHeaders() }).then(handle),
|
fetch(BASE + path, { method: 'DELETE', headers: authHeaders() }).then(handle),
|
||||||
upload: (path, formData) =>
|
upload: (path, formData) =>
|
||||||
fetch(BASE + path, { method: 'POST', body: formData, headers: authHeaders() }).then(handle),
|
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) =>
|
blob: (path) =>
|
||||||
fetch(BASE + path, { headers: authHeaders() }).then(async res => {
|
fetch(BASE + path, { headers: authHeaders() }).then(async res => {
|
||||||
if (!res.ok) { const t = await res.text(); throw new Error(t || res.statusText); }
|
if (!res.ok) { const t = await res.text(); throw new Error(t || res.statusText); }
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useInvestisseur } from '../context/InvestisseurContext.jsx';
|
|||||||
import { useUi } from '../context/UiContext.jsx';
|
import { useUi } from '../context/UiContext.jsx';
|
||||||
import Logo from './Logo.jsx';
|
import Logo from './Logo.jsx';
|
||||||
import UserMenu from './UserMenu.jsx';
|
import UserMenu from './UserMenu.jsx';
|
||||||
|
import NotificationBell from './NotificationBell.jsx';
|
||||||
|
|
||||||
/* ── Icônes nav ─────────────────────────────────────────────── */
|
/* ── Icônes nav ─────────────────────────────────────────────── */
|
||||||
const ICONS_BASE = '/api/icons-files/';
|
const ICONS_BASE = '/api/icons-files/';
|
||||||
@@ -406,6 +407,8 @@ export default function Layout() {
|
|||||||
Net
|
Net
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<NotificationBell />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -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) => (
|
||||||
|
<svg width={s} height={s} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<circle cx="12" cy="12" r="3"/>
|
||||||
|
<path d="M19.07 4.93a10 10 0 0 1 0 14.14M4.93 4.93a10 10 0 0 0 0 14.14"/>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
// Lucide: FileText
|
||||||
|
ticket_reply: (s) => (
|
||||||
|
<svg width={s} height={s} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||||
|
<polyline points="14 2 14 8 20 8"/>
|
||||||
|
<line x1="16" y1="13" x2="8" y2="13"/>
|
||||||
|
<line x1="16" y1="17" x2="8" y2="17"/>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
// Lucide: Info
|
||||||
|
info: (s) => (
|
||||||
|
<svg width={s} height={s} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<circle cx="12" cy="12" r="10"/>
|
||||||
|
<line x1="12" y1="8" x2="12" y2="12"/>
|
||||||
|
<line x1="12" y1="16" x2="12.01" y2="16"/>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
// Lucide: Users
|
||||||
|
team: (s) => (
|
||||||
|
<svg width={s} height={s} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
|
||||||
|
<circle cx="9" cy="7" r="4"/>
|
||||||
|
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
|
||||||
|
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
// Lucide: Check
|
||||||
|
success: (s) => (
|
||||||
|
<svg width={s} height={s} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<polyline points="20 6 9 17 4 12"/>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
// Lucide: AlertTriangle
|
||||||
|
warning: (s) => (
|
||||||
|
<svg width={s} height={s} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/>
|
||||||
|
<line x1="12" y1="9" x2="12" y2="13"/>
|
||||||
|
<line x1="12" y1="17" x2="12.01" y2="17"/>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
// Lucide: ShieldAlert
|
||||||
|
security: (s) => (
|
||||||
|
<svg width={s} height={s} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
|
||||||
|
<line x1="12" y1="8" x2="12" y2="12"/>
|
||||||
|
<line x1="12" y1="16" x2="12.01" y2="16"/>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
// Lucide: Megaphone
|
||||||
|
announcement: (s) => (
|
||||||
|
<svg width={s} height={s} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M3 11l19-9-9 19-2-8-8-2z"/>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
const FALLBACK = (s) => (
|
||||||
|
<svg width={s} height={s} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<circle cx="12" cy="12" r="10"/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div style={{
|
||||||
|
width: size, height: size, borderRadius: '50%', flexShrink: 0,
|
||||||
|
background: meta.bg, color: meta.color,
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
}}>
|
||||||
|
{renderIcon(iconSize)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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(
|
||||||
|
<div
|
||||||
|
ref={dropRef}
|
||||||
|
className="notif-dropdown"
|
||||||
|
style={{ top: dropPos.top, right: dropPos.right }}
|
||||||
|
>
|
||||||
|
<div className="notif-dropdown-header">
|
||||||
|
<span className="notif-dropdown-title">Notifications</span>
|
||||||
|
{unread > 0 && (
|
||||||
|
<button className="notif-mark-all-btn" onClick={markAll}>
|
||||||
|
Tout marquer lu
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="notif-dropdown-list">
|
||||||
|
{loading && (
|
||||||
|
<div className="notif-empty">Chargement…</div>
|
||||||
|
)}
|
||||||
|
{!loading && notifs.length === 0 && (
|
||||||
|
<div className="notif-empty">Aucune notification</div>
|
||||||
|
)}
|
||||||
|
{!loading && notifs.map(n => (
|
||||||
|
<div
|
||||||
|
key={n.id}
|
||||||
|
className={`notif-item${!n.read ? ' notif-item-unread' : ''}`}
|
||||||
|
onClick={() => handleNotifClick(n)}
|
||||||
|
>
|
||||||
|
<NotifTypeAvatar type={n.type} size={36} />
|
||||||
|
<div className="notif-item-body">
|
||||||
|
<div className="notif-item-title">{n.title}</div>
|
||||||
|
{n.body && <div className="notif-item-text">{n.body}</div>}
|
||||||
|
<div className="notif-item-time">{timeAgo(n.created_at)}</div>
|
||||||
|
</div>
|
||||||
|
{!n.read && <span className="notif-unread-dot" />}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="notif-dropdown-footer">
|
||||||
|
<button
|
||||||
|
className="notif-view-all-btn"
|
||||||
|
onClick={() => { setOpen(false); navigate('/notifications'); }}
|
||||||
|
>
|
||||||
|
Voir toutes les notifications
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
ref={bellRef}
|
||||||
|
className={`notif-bell-btn${unread > 0 ? ' has-unread' : ''}`}
|
||||||
|
onClick={handleOpen}
|
||||||
|
title="Notifications"
|
||||||
|
aria-label={`Notifications${unread > 0 ? ` (${unread} non lues)` : ''}`}
|
||||||
|
>
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<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>
|
||||||
|
{unread > 0 && (
|
||||||
|
<span className="notif-badge">{unread > 99 ? '99+' : unread}</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{dropdown}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -22,6 +22,9 @@ function IconSettings() {
|
|||||||
function IconAide() {
|
function IconAide() {
|
||||||
return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>;
|
return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>;
|
||||||
}
|
}
|
||||||
|
function IconComm() {
|
||||||
|
return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><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>;
|
||||||
|
}
|
||||||
function IconChevronRight() {
|
function IconChevronRight() {
|
||||||
return <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M9 18l6-6-6-6"/></svg>;
|
return <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M9 18l6-6-6-6"/></svg>;
|
||||||
}
|
}
|
||||||
@@ -259,6 +262,9 @@ export default function UserMenu() {
|
|||||||
<button className="user-menu-item" role="menuitem" onClick={() => go('/settings')}>
|
<button className="user-menu-item" role="menuitem" onClick={() => go('/settings')}>
|
||||||
<IconSettings /> Paramètres
|
<IconSettings /> Paramètres
|
||||||
</button>
|
</button>
|
||||||
|
<button className="user-menu-item" role="menuitem" onClick={() => go('/communication')}>
|
||||||
|
<IconComm /> Support & Notifications
|
||||||
|
</button>
|
||||||
<button className="user-menu-item" role="menuitem" onClick={() => go('/aide')}>
|
<button className="user-menu-item" role="menuitem" onClick={() => go('/aide')}>
|
||||||
<IconAide /> Aide
|
<IconAide /> Aide
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,291 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import { api } from '../api.js';
|
||||||
|
import { useAuth } from '../context/AuthContext.jsx';
|
||||||
|
import NotifTypeAvatar, { TYPE_META } from '../components/NotifTypeAvatar.jsx';
|
||||||
|
|
||||||
|
const PAGE_SIZE = 10;
|
||||||
|
|
||||||
|
// Dropdown générique (Status / Type)
|
||||||
|
function FilterDropdown({ label, value, options, onChange }) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const ref = useRef(null);
|
||||||
|
const current = options.find(o => o.value === value);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const h = (e) => { if (!ref.current?.contains(e.target)) setOpen(false); };
|
||||||
|
document.addEventListener('mousedown', h);
|
||||||
|
return () => document.removeEventListener('mousedown', h);
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} style={{ position: 'relative' }}>
|
||||||
|
<button
|
||||||
|
className="notif-filter-btn"
|
||||||
|
onClick={() => setOpen(o => !o)}
|
||||||
|
>
|
||||||
|
{label}{current && current.value !== options[0].value ? ` : ${current.label}` : ''}
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" style={{ marginLeft: 4 }}>
|
||||||
|
<polyline points="6 9 12 15 18 9"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div className="notif-filter-menu">
|
||||||
|
{options.map(o => (
|
||||||
|
<button
|
||||||
|
key={o.value}
|
||||||
|
className={`notif-filter-option${value === o.value ? ' active' : ''}`}
|
||||||
|
onClick={() => { onChange(o.value); setOpen(false); }}
|
||||||
|
>
|
||||||
|
{value === o.value && (
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" style={{ marginRight: 6, flexShrink: 0 }}>
|
||||||
|
<polyline points="20 6 9 17 4 12"/>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
{value !== o.value && <span style={{ width: 19, flexShrink: 0 }} />}
|
||||||
|
{o.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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', year: 'numeric' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_OPTIONS = [
|
||||||
|
{ value: 'all', label: 'Tous' },
|
||||||
|
{ value: 'unread', label: 'Non lus' },
|
||||||
|
{ value: 'read', label: 'Lus' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const TYPE_OPTIONS = [
|
||||||
|
{ value: 'all', label: 'Tous les types' },
|
||||||
|
...Object.entries(TYPE_META).map(([v, m]) => ({ value: v, label: m.label })),
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function Notifications() {
|
||||||
|
const { isAdmin } = useAuth();
|
||||||
|
const [notifs, setNotifs] = useState([]);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [page, setPage] = useState(0);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [filterStatus, setFilterStatus] = useState('all');
|
||||||
|
const [filterType, setFilterType] = useState('all');
|
||||||
|
const [seeding, setSeeding] = useState(false);
|
||||||
|
const [seedCount, setSeedCount] = useState(3);
|
||||||
|
|
||||||
|
const fetchNotifs = useCallback(async (p = 0, status = filterStatus, type = filterType) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const params = { limit: PAGE_SIZE, offset: p * PAGE_SIZE };
|
||||||
|
if (status === 'unread') params.unread_only = 'true';
|
||||||
|
if (type !== 'all') params.type = type;
|
||||||
|
const data = await api.get('/notifications', params);
|
||||||
|
setNotifs(data.notifications ?? []);
|
||||||
|
setTotal(data.total ?? 0);
|
||||||
|
} catch { /* silencieux */ }
|
||||||
|
setLoading(false);
|
||||||
|
}, [filterStatus, filterType]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchNotifs(page, filterStatus, filterType);
|
||||||
|
}, [page, filterStatus, filterType]); // eslint-disable-line
|
||||||
|
|
||||||
|
const handleStatus = (v) => { setFilterStatus(v); setPage(0); };
|
||||||
|
const handleType = (v) => { setFilterType(v); setPage(0); };
|
||||||
|
|
||||||
|
const markRead = async (id) => {
|
||||||
|
try {
|
||||||
|
await api.patch(`/notifications/${id}/read`);
|
||||||
|
setNotifs(prev => prev.map(n => n.id === id ? { ...n, read: 1 } : n));
|
||||||
|
} catch { /* silencieux */ }
|
||||||
|
};
|
||||||
|
|
||||||
|
const markAll = async () => {
|
||||||
|
try {
|
||||||
|
await api.patch('/notifications/read-all');
|
||||||
|
setNotifs(prev => prev.map(n => ({ ...n, read: 1 })));
|
||||||
|
} catch { /* silencieux */ }
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteNotif = async (id) => {
|
||||||
|
try {
|
||||||
|
await api.del(`/notifications/${id}`);
|
||||||
|
setNotifs(prev => prev.filter(n => n.id !== id));
|
||||||
|
setTotal(prev => Math.max(0, prev - 1));
|
||||||
|
} catch { /* silencieux */ }
|
||||||
|
};
|
||||||
|
|
||||||
|
const seedNotifs = async () => {
|
||||||
|
setSeeding(true);
|
||||||
|
try {
|
||||||
|
await api.post('/notifications/seed', { count: seedCount });
|
||||||
|
await fetchNotifs(0, filterStatus, filterType);
|
||||||
|
setPage(0);
|
||||||
|
// Forcer le rafraîchissement du compteur dans la cloche
|
||||||
|
window.dispatchEvent(new CustomEvent('notif:refresh'));
|
||||||
|
} catch { /* silencieux */ }
|
||||||
|
setSeeding(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Filtrage local par recherche (sur titre + body)
|
||||||
|
const visible = search.trim()
|
||||||
|
? notifs.filter(n =>
|
||||||
|
n.title.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
(n.body ?? '').toLowerCase().includes(search.toLowerCase())
|
||||||
|
)
|
||||||
|
: notifs;
|
||||||
|
|
||||||
|
const unreadTotal = notifs.filter(n => !n.read).length;
|
||||||
|
const totalPages = Math.ceil(total / PAGE_SIZE);
|
||||||
|
const from = page * PAGE_SIZE + 1;
|
||||||
|
const to = Math.min((page + 1) * PAGE_SIZE, total);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* topbar vide pour respecter le layout (pas de contenu ici) */}
|
||||||
|
<div className="topbar" style={{ display: 'none' }} aria-hidden />
|
||||||
|
|
||||||
|
{/* ── Wrapper centré (.main ajoute padding: 0 24px) ── */}
|
||||||
|
<div className="notif-center-wrap">
|
||||||
|
|
||||||
|
{/* ── Bloc principal notifications ── */}
|
||||||
|
<div className="card notif-block">
|
||||||
|
|
||||||
|
{/* En-tête du bloc */}
|
||||||
|
<div className="notif-block-header">
|
||||||
|
<h2 className="notif-page-title">Notifications</h2>
|
||||||
|
<button
|
||||||
|
className="notif-markall-btn"
|
||||||
|
onClick={markAll}
|
||||||
|
disabled={unreadTotal === 0}
|
||||||
|
>
|
||||||
|
<svg width="15" height="15" 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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Barre recherche + filtres */}
|
||||||
|
<div className="notif-toolbar" style={{ borderTop: '1px solid var(--border)' }}>
|
||||||
|
<div className="notif-search-wrap">
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="notif-search-icon">
|
||||||
|
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
||||||
|
</svg>
|
||||||
|
<input
|
||||||
|
className="notif-search-input"
|
||||||
|
placeholder="Rechercher des notifications…"
|
||||||
|
value={search}
|
||||||
|
onChange={e => setSearch(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="notif-filters">
|
||||||
|
<FilterDropdown label="Statut" value={filterStatus} options={STATUS_OPTIONS} onChange={handleStatus} />
|
||||||
|
<FilterDropdown label="Type" value={filterType} options={TYPE_OPTIONS} onChange={handleType} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Séparateur */}
|
||||||
|
<div style={{ borderTop: '1px solid var(--border)' }} />
|
||||||
|
|
||||||
|
{/* Liste */}
|
||||||
|
{loading && (
|
||||||
|
<div style={{ padding: 40, textAlign: 'center', color: 'var(--text-muted)', fontSize: 14 }}>
|
||||||
|
Chargement…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!loading && visible.length === 0 && (
|
||||||
|
<div style={{ padding: 56, textAlign: 'center', color: 'var(--text-muted)' }}>
|
||||||
|
<div style={{ fontSize: 36, marginBottom: 10 }}>🔔</div>
|
||||||
|
<div style={{ fontWeight: 600, marginBottom: 4 }}>Aucune notification</div>
|
||||||
|
<div style={{ fontSize: 13 }}>Vous êtes à jour !</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!loading && visible.map((n, i) => (
|
||||||
|
<div
|
||||||
|
key={n.id}
|
||||||
|
className={`notif-row${!n.read ? ' notif-row-unread' : ''}`}
|
||||||
|
style={{ borderTop: i === 0 ? 'none' : '1px solid var(--border)' }}
|
||||||
|
>
|
||||||
|
<NotifTypeAvatar type={n.type} />
|
||||||
|
<div className="notif-row-body">
|
||||||
|
<div className="notif-row-title">{n.title}</div>
|
||||||
|
{n.body && <div className="notif-row-text">{n.body}</div>}
|
||||||
|
</div>
|
||||||
|
<span className="notif-type-badge" style={{
|
||||||
|
background: (TYPE_META[n.type] ?? TYPE_META.info).bg,
|
||||||
|
color: (TYPE_META[n.type] ?? TYPE_META.info).color,
|
||||||
|
}}>
|
||||||
|
{(TYPE_META[n.type] ?? TYPE_META.info).label}
|
||||||
|
</span>
|
||||||
|
<span className="notif-row-time">{timeAgo(n.created_at)}</span>
|
||||||
|
<div className="notif-row-actions">
|
||||||
|
{!n.read && (
|
||||||
|
<button className="btn-icon-sm" title="Marquer comme lu" onClick={() => markRead(n.id)}>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><polyline points="20 6 9 17 4 12"/></svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button className="btn-icon-sm" title="Supprimer" onClick={() => deleteNotif(n.id)}>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><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>
|
||||||
|
</button>
|
||||||
|
{!n.read && <span className="notif-unread-dot" />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
<div className="notif-pagination">
|
||||||
|
<span className="notif-pagination-info">
|
||||||
|
{total === 0 ? 'Aucune notification' : `Affichage de ${from} à ${to} sur ${total} notification${total > 1 ? 's' : ''}`}
|
||||||
|
</span>
|
||||||
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
<button className="notif-page-btn" disabled={page === 0} onClick={() => setPage(p => p - 1)}>
|
||||||
|
Précédent
|
||||||
|
</button>
|
||||||
|
<button className="notif-page-btn" disabled={page >= totalPages - 1} onClick={() => setPage(p => p + 1)}>
|
||||||
|
Suivant
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>{/* fin .notif-block */}
|
||||||
|
|
||||||
|
{/* ── Bloc Simulation (admin uniquement) ── */}
|
||||||
|
{isAdmin && (
|
||||||
|
<div className="card notif-block" style={{ marginTop: 16 }}>
|
||||||
|
<div className="notif-block-header">
|
||||||
|
<h3 style={{ margin: 0, fontSize: 15, fontWeight: 600, color: 'var(--text)' }}>
|
||||||
|
Simulation
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div className="notif-admin-bar" style={{ borderTop: '1px solid var(--border)' }}>
|
||||||
|
<span style={{ color: 'var(--text-muted)', fontSize: 13 }}>Générer des notifications de test :</span>
|
||||||
|
<select
|
||||||
|
value={seedCount}
|
||||||
|
onChange={e => setSeedCount(Number(e.target.value))}
|
||||||
|
className="notif-seed-select"
|
||||||
|
>
|
||||||
|
{[1, 2, 3, 4, 5, 6].map(n => (
|
||||||
|
<option key={n} value={n}>{n} notif{n > 1 ? 's' : ''}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button className="btn btn-sm" onClick={seedNotifs} disabled={seeding} style={{ whiteSpace: 'nowrap' }}>
|
||||||
|
{seeding ? 'Création…' : '+ Créer'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</div>{/* fin .notif-center-wrap */}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user