Files
shoottracker/backend/src/routes/export.ts
T

173 lines
7.7 KiB
TypeScript

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<string, any> = {}
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' }))
})