import { Router } from 'express' import { eq, and, gte } from 'drizzle-orm' import { db } from '../db' import { training_sessions, weapons, series } from '../db/schema' import { requireAuth, AuthRequest } from '../middleware/auth' import { format } from 'date-fns' // @ts-ignore import { fr } from 'date-fns/locale' import jsPDF from 'jspdf' import 'jspdf-autotable' import * as XLSX from 'xlsx' export const exportRouter = Router() exportRouter.use(requireAuth) // ─── Export PDF d'une séance ────────────────────────────────────────────────── exportRouter.get('/pdf/:sessionId', async (req: AuthRequest, res) => { const sess = db.select().from(training_sessions) .where(and(eq(training_sessions.id, req.params.sessionId), eq(training_sessions.user_id, req.userId!))) .get() if (!sess) return res.status(404).json({ error: 'Séance introuvable' }) const weapon = sess.weapon_id ? db.select().from(weapons).where(eq(weapons.id, sess.weapon_id)).get() : null const seriesList = db.select().from(series).where(eq(series.session_id, sess.id)).all() const doc = new jsPDF({ orientation: 'portrait', unit: 'mm', format: 'a4' }) // En-tête doc.setFillColor(13, 13, 13) doc.rect(0, 0, 210, 35, 'F') doc.setTextColor(220, 38, 38) doc.setFontSize(22); doc.setFont('helvetica', 'bold') doc.text('SHOOTTRACKER', 14, 15) doc.setTextColor(180, 180, 180); doc.setFontSize(10); doc.setFont('helvetica', 'normal') doc.text('Rapport de séance d\'entraînement', 14, 22) doc.text(`Généré le ${format(new Date(), 'dd/MM/yyyy à HH:mm', { locale: fr })}`, 14, 28) // Infos séance doc.setTextColor(30, 30, 30); doc.setFontSize(14); doc.setFont('helvetica', 'bold') doc.text('Informations de séance', 14, 48) ;(doc as any).autoTable({ startY: 52, body: [ ['Date', format(new Date(sess.session_date), "dd MMMM yyyy 'à' HH:mm", { locale: fr })], ['Lieu', sess.location || 'Non précisé'], ['Arme', weapon ? `${weapon.name}${weapon.brand ? ' — ' + weapon.brand : ''}${weapon.model ? ' ' + weapon.model : ''}` : 'Non précisé'], ['Calibre', weapon?.caliber || 'Non précisé'], ['Type de cible', sess.target_type.toUpperCase()], ['Distance', sess.distance_m ? `${sess.distance_m} mètres` : 'Non précisé'], ], theme: 'striped', styles: { fontSize: 10, cellPadding: 3 }, columnStyles: { 0: { fontStyle: 'bold', cellWidth: 40 } }, }) // Résumé let y = (doc as any).lastAutoTable.finalY + 10 doc.setFontSize(14); doc.setFont('helvetica', 'bold'); doc.text('Résumé', 14, y) ;(doc as any).autoTable({ startY: y + 4, body: [ ['Séries', String(seriesList.length)], ['Tirs déclarés', String(sess.total_shots_declared)], ['Tirs détectés IA', `${sess.total_shots_detected} (${sess.ai_detection_rate}%)`], ['Score total', String(sess.total_score)], ['Score moyen/série', String(sess.avg_score)], ['Meilleure série', String(sess.best_series_score)], ], theme: 'grid', styles: { fontSize: 10, cellPadding: 3 }, columnStyles: { 0: { fontStyle: 'bold', cellWidth: 70 }, 1: { textColor: [220, 38, 38], fontStyle: 'bold' } }, }) // Tableau des séries y = (doc as any).lastAutoTable.finalY + 10 if (y > 240) { doc.addPage(); y = 20 } doc.setFontSize(14); doc.setFont('helvetica', 'bold'); doc.text('Détail des séries', 14, y) if (seriesList.length > 0) { ;(doc as any).autoTable({ startY: y + 4, head: [['Série', 'Déclarés', 'Détectés', 'Manuels', 'Zones', 'Groupement', 'Total', 'Dispersion']], body: seriesList.map((s: any) => [ s.series_number, s.shots_declared, s.shots_detected, s.shots_manual || '—', s.score_zone, s.score_groupement, s.score_total, s.dispersion_radius ? Number(s.dispersion_radius).toFixed(1) : '—', ]), theme: 'striped', styles: { fontSize: 9, cellPadding: 2.5 }, headStyles: { fillColor: [220, 38, 38], textColor: [255, 255, 255], fontStyle: 'bold' }, }) } // Notes if (sess.notes_start || sess.notes_end) { y = (doc as any).lastAutoTable.finalY + 10 if (y > 240) { doc.addPage(); y = 20 } doc.setFontSize(14); doc.setFont('helvetica', 'bold'); doc.text('Notes', 14, y); y += 6 doc.setFontSize(10); doc.setFont('helvetica', 'normal') if (sess.notes_start) { doc.setFont('helvetica', 'bold'); doc.text('Début :', 14, y); y += 5 doc.setFont('helvetica', 'normal') const lines = doc.splitTextToSize(sess.notes_start, 180) doc.text(lines, 14, y); y += lines.length * 5 + 3 } if (sess.notes_end) { doc.setFont('helvetica', 'bold'); doc.text('Fin :', 14, y); y += 5 doc.setFont('helvetica', 'normal') const lines = doc.splitTextToSize(sess.notes_end, 180) doc.text(lines, 14, y) } } // Footer const pc = doc.getNumberOfPages() for (let i = 1; i <= pc; i++) { doc.setPage(i); doc.setFontSize(8); doc.setTextColor(150) doc.text(`ShootTracker — Page ${i}/${pc}`, 105, 290, { align: 'center' }) } const filename = `shoottracker_${format(new Date(sess.session_date), 'yyyy-MM-dd')}.pdf` res.setHeader('Content-Type', 'application/pdf') res.setHeader('Content-Disposition', `attachment; filename="${filename}"`) res.send(Buffer.from(doc.output('arraybuffer'))) }) // ─── Export Excel progression ───────────────────────────────────────────────── exportRouter.get('/excel', (req: AuthRequest, res) => { const { period, weaponId } = req.query as { period?: string; weaponId?: string } let query = db.select().from(training_sessions) .$dynamic() const filters = [eq(training_sessions.user_id, req.userId!), eq(training_sessions.is_completed, true)] const now = new Date() if (period === '30j') filters.push(gte(training_sessions.session_date, new Date(now.getTime() - 30*86400000).toISOString())) if (period === '90j') filters.push(gte(training_sessions.session_date, new Date(now.getTime() - 90*86400000).toISOString())) if (period === '1an') filters.push(gte(training_sessions.session_date, new Date(now.getTime() - 365*86400000).toISOString())) const sessions = db.select().from(training_sessions) .where(and(...filters)).all() const weaponMap: Record = {} sessions.forEach(s => { if (s.weapon_id && !weaponMap[s.weapon_id]) { const w = db.select().from(weapons).where(eq(weapons.id, s.weapon_id)).get() if (w) weaponMap[w.id] = w } }) const rows = sessions .filter(s => !weaponId || weaponId === 'all' || s.weapon_id === weaponId) .map(s => ({ 'Date': format(new Date(s.session_date), 'dd/MM/yyyy HH:mm'), 'Lieu': s.location || '', 'Arme': weaponMap[s.weapon_id || '']?.name || '', 'Distance (m)': s.distance_m || '', 'Type cible': s.target_type, 'Tirs déclarés': s.total_shots_declared, 'Tirs détectés IA': s.total_shots_detected, 'Taux détection (%)': s.ai_detection_rate, 'Score total': s.total_score, 'Score moyen/série': s.avg_score, 'Meilleure série': s.best_series_score, 'Dispersion moy.': s.avg_dispersion || '', })) const ws = XLSX.utils.json_to_sheet(rows) ws['!cols'] = Array(12).fill({ wch: 18 }) const wb = XLSX.utils.book_new() XLSX.utils.book_append_sheet(wb, ws, 'Séances') const filename = `shoottracker_${format(new Date(), 'yyyy-MM-dd')}.xlsx` res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet') res.setHeader('Content-Disposition', `attachment; filename="${filename}"`) res.send(XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' })) })