173 lines
7.7 KiB
TypeScript
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' }))
|
|
})
|