Observer dashboard extraction, PDF reports, jury UX overhaul, and miscellaneous improvements

- Extract observer dashboard to client component, add PDF export button
- Add PDF report generator with jsPDF for analytics reports
- Overhaul jury evaluation page with improved layout and UX
- Add new analytics endpoints for observer/admin reports
- Improve round creation/edit forms with better settings
- Fix filtering rules page, CSV export dialog, notification bell
- Update auth, prisma schema, and various type fixes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-10 23:08:00 +01:00
parent 5c8d22ac11
commit d787a24921
31 changed files with 2565 additions and 930 deletions

422
src/lib/pdf-generator.ts Normal file
View File

@@ -0,0 +1,422 @@
import { jsPDF } from 'jspdf'
import { autoTable } from 'jspdf-autotable'
import html2canvas from 'html2canvas'
// =========================================================================
// Brand constants
// =========================================================================
const COLORS = {
darkBlue: '#053d57',
red: '#de0f1e',
teal: '#557f8c',
lightGray: '#f0f4f8',
white: '#ffffff',
textDark: '#1a1a1a',
textMuted: '#888888',
} as const
const DARK_BLUE_RGB: [number, number, number] = [5, 61, 87]
const TEAL_RGB: [number, number, number] = [85, 127, 140]
const RED_RGB: [number, number, number] = [222, 15, 30]
const LIGHT_GRAY_RGB: [number, number, number] = [240, 244, 248]
const PAGE_WIDTH = 210 // A4 mm
const PAGE_HEIGHT = 297
const MARGIN = 15
const CONTENT_WIDTH = PAGE_WIDTH - MARGIN * 2
// =========================================================================
// Font & logo caching
// =========================================================================
let cachedFonts: { regular: string; bold: string } | null = null
let cachedLogo: string | null = null
let fontLoadAttempted = false
let logoLoadAttempted = false
async function loadFonts(): Promise<{ regular: string; bold: string } | null> {
if (cachedFonts) return cachedFonts
if (fontLoadAttempted) return null
fontLoadAttempted = true
try {
const [regularRes, boldRes] = await Promise.all([
fetch('/fonts/Montserrat-Regular.ttf'),
fetch('/fonts/Montserrat-Bold.ttf'),
])
if (!regularRes.ok || !boldRes.ok) return null
const [regularBuf, boldBuf] = await Promise.all([
regularRes.arrayBuffer(),
boldRes.arrayBuffer(),
])
const toBase64 = (buf: ArrayBuffer) => {
const bytes = new Uint8Array(buf)
let binary = ''
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i])
}
return btoa(binary)
}
cachedFonts = { regular: toBase64(regularBuf), bold: toBase64(boldBuf) }
return cachedFonts
} catch {
return null
}
}
async function loadLogo(): Promise<string | null> {
if (cachedLogo) return cachedLogo
if (logoLoadAttempted) return null
logoLoadAttempted = true
try {
const res = await fetch('/images/MOPC-blue-long.png')
if (!res.ok) return null
const blob = await res.blob()
return new Promise((resolve) => {
const reader = new FileReader()
reader.onloadend = () => {
cachedLogo = reader.result as string
resolve(cachedLogo)
}
reader.onerror = () => resolve(null)
reader.readAsDataURL(blob)
})
} catch {
return null
}
}
// =========================================================================
// Document creation
// =========================================================================
export interface ReportDocumentOptions {
orientation?: 'portrait' | 'landscape'
}
export async function createReportDocument(
options?: ReportDocumentOptions
): Promise<jsPDF> {
const doc = new jsPDF({
orientation: options?.orientation || 'portrait',
unit: 'mm',
format: 'a4',
})
// Load and register fonts
const fonts = await loadFonts()
if (fonts) {
doc.addFileToVFS('Montserrat-Regular.ttf', fonts.regular)
doc.addFont('Montserrat-Regular.ttf', 'Montserrat', 'normal')
doc.addFileToVFS('Montserrat-Bold.ttf', fonts.bold)
doc.addFont('Montserrat-Bold.ttf', 'Montserrat', 'bold')
doc.setFont('Montserrat', 'normal')
} else {
doc.setFont('helvetica', 'normal')
}
return doc
}
// =========================================================================
// Cover page
// =========================================================================
export interface CoverPageOptions {
title: string
subtitle?: string
roundName?: string
programName?: string
}
export async function addCoverPage(
doc: jsPDF,
options: CoverPageOptions
): Promise<void> {
const logo = await loadLogo()
// Logo centered
if (logo) {
const logoWidth = 80
const logoHeight = 20
const logoX = (PAGE_WIDTH - logoWidth) / 2
doc.addImage(logo, 'PNG', logoX, 60, logoWidth, logoHeight)
}
// Title
const fontName = getFont(doc)
doc.setFont(fontName, 'bold')
doc.setFontSize(24)
doc.setTextColor(...DARK_BLUE_RGB)
doc.text(options.title, PAGE_WIDTH / 2, logo ? 110 : 100, { align: 'center' })
// Subtitle
if (options.subtitle) {
doc.setFont(fontName, 'normal')
doc.setFontSize(14)
doc.setTextColor(...TEAL_RGB)
doc.text(options.subtitle, PAGE_WIDTH / 2, logo ? 125 : 115, { align: 'center' })
}
// Round & program
let infoY = logo ? 145 : 135
doc.setFontSize(12)
doc.setTextColor(...DARK_BLUE_RGB)
if (options.programName) {
doc.text(options.programName, PAGE_WIDTH / 2, infoY, { align: 'center' })
infoY += 8
}
if (options.roundName) {
doc.setFont(fontName, 'bold')
doc.text(options.roundName, PAGE_WIDTH / 2, infoY, { align: 'center' })
infoY += 8
}
// Date
doc.setFont(fontName, 'normal')
doc.setFontSize(10)
doc.setTextColor(136, 136, 136)
doc.text(
`Generated on ${new Date().toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' })}`,
PAGE_WIDTH / 2,
infoY + 10,
{ align: 'center' }
)
// Decorative line
doc.setDrawColor(...TEAL_RGB)
doc.setLineWidth(0.5)
doc.line(MARGIN + 30, infoY + 20, PAGE_WIDTH - MARGIN - 30, infoY + 20)
}
// =========================================================================
// Header (on content pages)
// =========================================================================
export async function addHeader(doc: jsPDF, title: string): Promise<void> {
const logo = await loadLogo()
if (logo) {
doc.addImage(logo, 'PNG', MARGIN, 8, 30, 8)
}
const fontName = getFont(doc)
doc.setFont(fontName, 'bold')
doc.setFontSize(11)
doc.setTextColor(...DARK_BLUE_RGB)
doc.text(title, PAGE_WIDTH / 2, 14, { align: 'center' })
doc.setFont(fontName, 'normal')
doc.setFontSize(8)
doc.setTextColor(136, 136, 136)
doc.text(
new Date().toLocaleDateString('en-GB'),
PAGE_WIDTH - MARGIN,
14,
{ align: 'right' }
)
// Line under header
doc.setDrawColor(...TEAL_RGB)
doc.setLineWidth(0.3)
doc.line(MARGIN, 18, PAGE_WIDTH - MARGIN, 18)
}
// =========================================================================
// Footer
// =========================================================================
export function addFooter(
doc: jsPDF,
pageNumber: number,
totalPages: number
): void {
const fontName = getFont(doc)
const y = PAGE_HEIGHT - 10
doc.setFont(fontName, 'normal')
doc.setFontSize(7)
doc.setTextColor(136, 136, 136)
doc.text('Generated by MOPC Platform', MARGIN, y)
doc.text('Confidential', PAGE_WIDTH / 2, y, { align: 'center' })
doc.text(`Page ${pageNumber} of ${totalPages}`, PAGE_WIDTH - MARGIN, y, {
align: 'right',
})
}
export function addAllPageFooters(doc: jsPDF): void {
const totalPages = doc.getNumberOfPages()
for (let i = 1; i <= totalPages; i++) {
doc.setPage(i)
addFooter(doc, i, totalPages)
}
}
// =========================================================================
// Section title
// =========================================================================
export function addSectionTitle(doc: jsPDF, title: string, y: number): number {
const fontName = getFont(doc)
doc.setFont(fontName, 'bold')
doc.setFontSize(16)
doc.setTextColor(...DARK_BLUE_RGB)
doc.text(title, MARGIN, y)
// Teal underline
doc.setDrawColor(...TEAL_RGB)
doc.setLineWidth(0.5)
doc.line(MARGIN, y + 2, MARGIN + doc.getTextWidth(title), y + 2)
return y + 12
}
// =========================================================================
// Stat cards row
// =========================================================================
export function addStatCards(
doc: jsPDF,
stats: Array<{ label: string; value: string | number }>,
y: number
): number {
const fontName = getFont(doc)
const cardCount = Math.min(stats.length, 4)
const gap = 4
const cardWidth = (CONTENT_WIDTH - gap * (cardCount - 1)) / cardCount
const cardHeight = 22
for (let i = 0; i < cardCount; i++) {
const x = MARGIN + i * (cardWidth + gap)
// Card background
doc.setFillColor(...LIGHT_GRAY_RGB)
doc.roundedRect(x, y, cardWidth, cardHeight, 2, 2, 'F')
// Value
doc.setFont(fontName, 'bold')
doc.setFontSize(18)
doc.setTextColor(...DARK_BLUE_RGB)
doc.text(String(stats[i].value), x + cardWidth / 2, y + 10, {
align: 'center',
})
// Label
doc.setFont(fontName, 'normal')
doc.setFontSize(8)
doc.setTextColor(...TEAL_RGB)
doc.text(stats[i].label, x + cardWidth / 2, y + 18, { align: 'center' })
}
return y + cardHeight + 8
}
// =========================================================================
// Table via autoTable
// =========================================================================
export function addTable(
doc: jsPDF,
headers: string[],
rows: (string | number)[][],
y: number
): number {
const fontName = getFont(doc)
autoTable(doc, {
startY: y,
head: [headers],
body: rows,
margin: { left: MARGIN, right: MARGIN },
styles: {
font: fontName,
fontSize: 9,
cellPadding: 3,
textColor: [26, 26, 26],
},
headStyles: {
fillColor: DARK_BLUE_RGB,
textColor: [255, 255, 255],
fontStyle: 'bold',
fontSize: 9,
},
alternateRowStyles: {
fillColor: [248, 248, 248],
},
theme: 'grid',
tableLineColor: [220, 220, 220],
tableLineWidth: 0.1,
})
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const finalY = (doc as any).lastAutoTable?.finalY ?? y + 20
return finalY + 8
}
// =========================================================================
// Chart image capture
// =========================================================================
export async function addChartImage(
doc: jsPDF,
element: HTMLElement,
y: number,
options?: { maxHeight?: number }
): Promise<number> {
const canvas = await html2canvas(element, {
scale: 2,
useCORS: true,
backgroundColor: COLORS.white,
logging: false,
})
const imgData = canvas.toDataURL('image/jpeg', 0.95)
const imgWidth = CONTENT_WIDTH
const ratio = canvas.height / canvas.width
let imgHeight = imgWidth * ratio
const maxH = options?.maxHeight || 100
if (imgHeight > maxH) {
imgHeight = maxH
}
// Check page break
y = checkPageBreak(doc, y, imgHeight + 5)
doc.addImage(imgData, 'JPEG', MARGIN, y, imgWidth, imgHeight)
return y + imgHeight + 8
}
// =========================================================================
// Page break helper
// =========================================================================
export function checkPageBreak(
doc: jsPDF,
y: number,
neededHeight: number
): number {
const availableHeight = PAGE_HEIGHT - 20 // leave room for footer
if (y + neededHeight > availableHeight) {
doc.addPage()
return 25 // start below header area
}
return y
}
export function addPageBreak(doc: jsPDF): void {
doc.addPage()
}
// =========================================================================
// Save
// =========================================================================
export function savePdf(doc: jsPDF, filename: string): void {
doc.save(filename)
}
// =========================================================================
// Helper
// =========================================================================
function getFont(doc: jsPDF): string {
// Check if Montserrat was loaded
try {
const fonts = doc.getFontList()
if (fonts['Montserrat']) return 'Montserrat'
} catch {
// Fallback
}
return 'helvetica'
}