Apply full refactor updates plus pipeline/email UX confirmations
All checks were successful
Build and Push Docker Image / build (push) Successful in 10m33s
All checks were successful
Build and Push Docker Image / build (push) Successful in 10m33s
This commit is contained in:
@@ -1,422 +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'
|
||||
}
|
||||
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'
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user