From 4bdbfc82ca21f03c5ebbf7d1a88db1e7191fdd7b Mon Sep 17 00:00:00 2001 From: fullsizemalt <106900403+fullsizemalt@users.noreply.github.com> Date: Thu, 8 Jan 2026 12:07:35 -0800 Subject: [PATCH] Add tinypdf-plus integration with PDF generation and branding Backend changes: - Add tinypdf-plus dependency for TTF/OTF font support - Create PDF service with text, certificate, and label generation - Add PDF API endpoints (/api/pdf/*) - Add branding service for custom fonts and styling - Add branding API endpoints (/api/branding/*) - Add font registration endpoint for custom fonts Frontend changes: - Add PDF library with download utilities - Add PDFDownloadButton component for reusable PDF downloads - Add branding API client for managing branding configs - Update ReportsPage with PDF generation tab Co-Authored-By: Claude --- backend/package.json | 1 + backend/src/routes/branding.routes.ts | 94 ++++++ backend/src/routes/pdf.routes.ts | 131 ++++++++ backend/src/server.ts | 6 + backend/src/services/branding.service.ts | 143 +++++++++ backend/src/services/pdf.service.ts | 283 ++++++++++++++++++ .../src/components/ui/PDFDownloadButton.tsx | 180 +++++++++++ frontend/src/lib/branding.ts | 144 +++++++++ frontend/src/lib/pdf.ts | 145 +++++++++ frontend/src/pages/ReportsPage.tsx | 147 ++++++++- 10 files changed, 1272 insertions(+), 2 deletions(-) create mode 100644 backend/src/routes/branding.routes.ts create mode 100644 backend/src/routes/pdf.routes.ts create mode 100644 backend/src/services/branding.service.ts create mode 100644 backend/src/services/pdf.service.ts create mode 100644 frontend/src/components/ui/PDFDownloadButton.tsx create mode 100644 frontend/src/lib/branding.ts create mode 100644 frontend/src/lib/pdf.ts diff --git a/backend/package.json b/backend/package.json index 668cd4d..0f114ca 100644 --- a/backend/package.json +++ b/backend/package.json @@ -30,6 +30,7 @@ "fastify-plugin": "^4.5.0", "jsonwebtoken": "^9.0.3", "sharp": "^0.33.0", + "tinypdf-plus": "https://github.com/fullsizemalt/tinypdf-plus.git", "zod": "^3.22.4" }, "devDependencies": { diff --git a/backend/src/routes/branding.routes.ts b/backend/src/routes/branding.routes.ts new file mode 100644 index 0000000..1ef3b1c --- /dev/null +++ b/backend/src/routes/branding.routes.ts @@ -0,0 +1,94 @@ +/** + * Branding Routes + * API endpoints for managing PDF branding configurations + */ + +import { FastifyInstance } from 'fastify'; +import { z } from 'zod'; +import { brandingService } from '../services/branding.service'; + +const brandingSchema = z.object({ + name: z.string().min(1).optional(), + titleFont: z.string().optional(), + bodyFont: z.string().optional(), + labelFont: z.string().optional(), + primaryColor: z.string().regex(/^#[0-9A-Fa-f]{6}$/).optional(), + secondaryColor: z.string().regex(/^#[0-9A-Fa-f]{6}$/).optional(), + accentColor: z.string().regex(/^#[0-9A-Fa-f]{6}$/).optional(), + logoData: z.string().optional(), +}); + +export async function brandingRoutes(fastify: FastifyInstance) { + // Get all branding configurations + fastify.get('/api/branding', async (request, reply) => { + try { + const brandings = brandingService.listBrandings(); + reply.send({ brandings }); + } catch (error: any) { + reply.code(500).send({ error: 'Failed to list brandings', message: error?.message }); + } + }); + + // Get a specific branding configuration + fastify.get<{ Params: { id: string } }>('/api/branding/:id', async (request, reply) => { + try { + const { id } = request.params; + const branding = brandingService.getBranding(id); + + if (!branding) { + return reply.code(404).send({ error: 'Branding not found' }); + } + + reply.send(branding); + } catch (error: any) { + reply.code(500).send({ error: 'Failed to get branding', message: error?.message }); + } + }); + + // Create or update branding + fastify.post<{ Params: { id?: string } }>('/api/branding/:id?', async (request, reply) => { + try { + const { id } = request.params; + const body = brandingSchema.parse(request.body); + + const branding = await brandingService.saveBranding({ + ...body, + id, + }); + + reply.send(branding); + } catch (error: any) { + if (error.name === 'ZodError') { + return reply.code(400).send({ error: 'Invalid request', details: error.errors }); + } + reply.code(500).send({ error: 'Failed to save branding', message: error?.message }); + } + }); + + // Delete branding + fastify.delete<{ Params: { id: string } }>('/api/branding/:id', async (request, reply) => { + try { + const { id } = request.params; + + const deleted = await brandingService.deleteBranding(id); + + if (!deleted) { + return reply.code(404).send({ error: 'Branding not found' }); + } + + reply.send({ success: true, message: 'Branding deleted' }); + } catch (error: any) { + reply.code(500).send({ error: 'Failed to delete branding', message: error?.message }); + } + }); + + // Get default branding + fastify.get('/api/branding/default', async (request, reply) => { + try { + const branding = brandingService.getDefaultBranding(); + reply.send(branding); + } catch (error: any) { + reply.code(500).send({ error: 'Failed to get default branding', message: error?.message }); + } + }); +} diff --git a/backend/src/routes/pdf.routes.ts b/backend/src/routes/pdf.routes.ts new file mode 100644 index 0000000..aabd6a7 --- /dev/null +++ b/backend/src/routes/pdf.routes.ts @@ -0,0 +1,131 @@ +/** + * PDF Generation Routes + * API endpoints for generating various types of PDFs + */ + +import { FastifyInstance } from 'fastify' +import { z } from 'zod' +import { pdfService } from '../services/pdf.service' + +// Validation schemas +const textPDFSchema = z.object({ + content: z.string().min(1), + options: z.object({ + width: z.number().default(612), + height: z.number().default(792), + margin: z.number().default(72) + }).optional() +}) + +const certificateSchema = z.object({ + title: z.string().min(1), + recipientName: z.string().min(1), + description: z.string().min(1), + date: z.string(), + certificateNumber: z.string().min(1), + authorizedBy: z.string().optional() +}) + +const labelSchema = z.object({ + title: z.string().min(1), + subtitle: z.string().optional(), + qrCode: z.string().optional(), + details: z.array(z.object({ + label: z.string(), + value: z.string() + })).optional().default([]) +}) + +export async function pdfRoutes(fastify: FastifyInstance) { + // Register a custom font for PDF generation + fastify.post('/api/pdf/fonts/register', async (request, reply) => { + try { + const data = request.body as { name: string; fontData: string }; // base64 encoded font data + + if (!data.name || !data.fontData) { + return reply.code(400).send({ error: 'Missing font name or data' }); + } + + // Convert base64 to Uint8Array + const binaryString = Buffer.from(data.fontData, 'base64').toString('binary'); + const fontBytes = new Uint8Array(binaryString.length); + for (let i = 0; i < binaryString.length; i++) { + fontBytes[i] = binaryString.charCodeAt(i); + } + + pdfService.registerFont(data.name, fontBytes); + + reply.send({ + success: true, + font: data.name, + message: `Font "${data.name}" registered successfully` + }); + } catch (error: any) { + reply.code(500).send({ error: 'Font registration failed', message: error?.message || 'Unknown error' }); + } + }); + + // List registered fonts + fastify.get('/api/pdf/fonts', async (request, reply) => { + try { + const fonts = pdfService.getRegisteredFonts(); + reply.send({ fonts }); + } catch (error: any) { + reply.code(500).send({ error: 'Failed to list fonts', message: error?.message || 'Unknown error' }); + } + }); + + // Generate a simple text PDF + fastify.post('/api/pdf/text', async (request, reply) => { + try { + const body = textPDFSchema.parse(request.body) + const pdf = pdfService.generateTextPDF(body.content, body.options || { width: 612, height: 792, margin: 72 }) + + reply + .type('application/pdf') + .header('Content-Disposition', 'attachment; filename="document.pdf"') + .send(pdf) + } catch (error: any) { + reply.code(400).send({ error: 'Invalid request', message: error?.message || 'Unknown error' }) + } + }) + + // Generate a certificate PDF + fastify.post('/api/pdf/certificate', async (request, reply) => { + try { + const body = certificateSchema.parse(request.body) + const pdf = pdfService.generateCertificate(body) + + reply + .type('application/pdf') + .header('Content-Disposition', `attachment; filename="certificate-${body.certificateNumber}.pdf"`) + .send(pdf) + } catch (error: any) { + reply.code(400).send({ error: 'Invalid request', message: error?.message || 'Unknown error' }) + } + }) + + // Generate a label PDF + fastify.post('/api/pdf/label', async (request, reply) => { + try { + const body = labelSchema.parse(request.body) + const pdf = pdfService.generateLabel(body) + + reply + .type('application/pdf') + .header('Content-Disposition', 'attachment; filename="label.pdf"') + .send(pdf) + } catch (error: any) { + reply.code(400).send({ error: 'Invalid request', message: error?.message || 'Unknown error' }) + } + }) + + // Health check for PDF service + fastify.get('/api/pdf/health', async (request, reply) => { + reply.send({ + status: 'ok', + service: 'tinypdf-plus', + timestamp: new Date().toISOString() + }) + }) +} diff --git a/backend/src/server.ts b/backend/src/server.ts index ab5ba17..bebcfe8 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -72,6 +72,12 @@ server.register(messagingRoutes, { prefix: '/api/messaging' }); import { plantRoutes } from './routes/plants.routes'; server.register(plantRoutes, { prefix: '/api/plants' }); +// PDF generation +import { pdfRoutes } from './routes/pdf.routes'; +import { brandingRoutes } from './routes/branding.routes'; +server.register(pdfRoutes); +server.register(brandingRoutes); + // Phase 10: Compliance import { auditRoutes } from './routes/audit.routes'; import { documentRoutes } from './routes/documents.routes'; diff --git a/backend/src/services/branding.service.ts b/backend/src/services/branding.service.ts new file mode 100644 index 0000000..2760638 --- /dev/null +++ b/backend/src/services/branding.service.ts @@ -0,0 +1,143 @@ +/** + * Branding Service + * Manages custom branding settings for PDF generation + */ + +import { randomUUID } from 'crypto'; +import { promises as fs } from 'fs'; +import path from 'path'; + +export interface BrandingConfig { + id: string; + name: string; + // Font references (stored separately) + titleFont?: string; + bodyFont?: string; + labelFont?: string; + // Colors + primaryColor?: string; + secondaryColor?: string; + accentColor?: string; + // Logo + logoData?: string; // base64 encoded image + // Metadata + createdAt: Date; + updatedAt: Date; +} + +class BrandingService { + private configs: Map = new Map(); + private storagePath: string; + + constructor() { + this.storagePath = path.join(process.cwd(), 'storage', 'branding'); + this.ensureStorageDirectory(); + this.loadDefaultBranding(); + } + + private async ensureStorageDirectory() { + try { + await fs.mkdir(this.storagePath, { recursive: true }); + } catch (error) { + console.warn('Could not create branding storage directory:', error); + } + } + + private loadDefaultBranding() { + // Create a default branding configuration + const defaultBranding: BrandingConfig = { + id: 'default', + name: 'Default', + createdAt: new Date(), + updatedAt: new Date(), + }; + this.configs.set('default', defaultBranding); + } + + /** + * Get a branding configuration by ID + */ + getBranding(id: string): BrandingConfig | undefined { + return this.configs.get(id); + } + + /** + * Get the default branding configuration + */ + getDefaultBranding(): BrandingConfig { + return this.configs.get('default')!; + } + + /** + * Create or update a branding configuration + */ + async saveBranding(config: Partial): Promise { + const id = config.id || randomUUID(); + const existing = this.configs.get(id); + + const branding: BrandingConfig = { + id, + name: config.name || 'Custom Branding', + titleFont: config.titleFont, + bodyFont: config.bodyFont, + labelFont: config.labelFont, + primaryColor: config.primaryColor, + secondaryColor: config.secondaryColor, + accentColor: config.accentColor, + logoData: config.logoData, + createdAt: existing?.createdAt || new Date(), + updatedAt: new Date(), + }; + + this.configs.set(id, branding); + + // Persist to disk + await this.persistBranding(id, branding); + + return branding; + } + + /** + * List all branding configurations + */ + listBrandings(): BrandingConfig[] { + return Array.from(this.configs.values()); + } + + /** + * Delete a branding configuration + */ + async deleteBranding(id: string): Promise { + if (id === 'default') { + throw new Error('Cannot delete default branding'); + } + + const deleted = this.configs.delete(id); + + if (deleted) { + // Remove from disk + try { + const filePath = path.join(this.storagePath, `${id}.json`); + await fs.unlink(filePath); + } catch (error) { + console.warn(`Could not delete branding file for ${id}:`, error); + } + } + + return deleted; + } + + /** + * Persist branding configuration to disk + */ + private async persistBranding(id: string, branding: BrandingConfig) { + try { + const filePath = path.join(this.storagePath, `${id}.json`); + await fs.writeFile(filePath, JSON.stringify(branding, null, 2)); + } catch (error) { + console.error(`Failed to persist branding ${id}:`, error); + } + } +} + +export const brandingService = new BrandingService(); diff --git a/backend/src/services/pdf.service.ts b/backend/src/services/pdf.service.ts new file mode 100644 index 0000000..24fac6c --- /dev/null +++ b/backend/src/services/pdf.service.ts @@ -0,0 +1,283 @@ +/** + * PDF Generation Service + * Uses tinypdf-plus for server-side PDF generation with custom font support + */ + +// @ts-ignore - tinypdf-plus doesn't have types yet +import { pdf, loadFont } from 'tinypdf-plus' +import { randomUUID } from 'crypto' + +export interface PDFOptions { + title?: string + author?: string + subject?: string + keywords?: string + creator?: string +} + +export interface TextOptions { + font?: string + color?: string + size?: number + align?: 'left' | 'center' | 'right' + width?: number +} + +export interface PDFDocumentConfig { + width: number + height: number + margin: number +} + +export interface FontConfig { + name: string + data: Uint8Array +} + +class PDFService { + private registeredFonts = new Map() + + /** + * Register a custom font for PDF generation + */ + registerFont(name: string, fontData: Uint8Array): void { + try { + const font = loadFont(fontData, name) + this.registeredFonts.set(name, { name, data: fontData }) + } catch (error) { + console.error(`Failed to register font ${name}:`, error) + throw new Error(`Font registration failed: ${error}`) + } + } + + /** + * Get list of registered fonts + */ + getRegisteredFonts(): string[] { + return Array.from(this.registeredFonts.keys()) + } + + /** + * Check if a font is registered + */ + hasFont(name: string): boolean { + return this.registeredFonts.has(name) + } + + /** + * Generate a simple text PDF + */ + generateTextPDF( + content: string, + options: PDFDocumentConfig = { width: 612, height: 792, margin: 72 } + ): Uint8Array { + const doc = pdf() + + // Register any custom fonts + for (const font of this.registeredFonts.values()) { + try { + const loadedFont = loadFont(font.data, font.name) + doc.registerFont(font.name, loadedFont) + } catch (error) { + console.warn(`Failed to register font ${font.name}:`, error) + } + } + + doc.page((ctx: any) => { + const { width, height, margin } = options + const textWidth = width - margin * 2 + let y = height - margin + const lineHeight = 14 + + // Simple text wrapping + const lines = this.wrapText(content, textWidth, 12) + for (const line of lines) { + ctx.text(line, margin, y, 12) + y -= lineHeight + if (y < margin) break + } + }) + + return doc.build() + } + + /** + * Generate a certificate PDF + */ + generateCertificate( + data: { + title: string + recipientName: string + description: string + date: string + certificateNumber: string + authorizedBy?: string + }, + options: PDFDocumentConfig = { width: 612, height: 792, margin: 72 } + ): Uint8Array { + const doc = pdf() + + // Register any custom fonts + for (const font of this.registeredFonts.values()) { + try { + const loadedFont = loadFont(font.data, font.name) + doc.registerFont(font.name, loadedFont) + } catch (error) { + console.warn(`Failed to register font ${font.name}:`, error) + } + } + + doc.page((ctx: any) => { + const { width, height, margin } = options + const centerX = width / 2 + let y = height - margin + + // Title + ctx.text(data.title, centerX, y, 24, { + font: this.registeredFonts.has('Title') ? 'Title' : undefined, + align: 'center', + width: width - margin * 2 + }) + y -= 50 + + // Certificate number + ctx.text(`Certificate No: ${data.certificateNumber}`, centerX, y, 10, { + color: '#666', + align: 'center', + width: width - margin * 2 + }) + y -= 40 + + // Separator line + ctx.line(margin, y, width - margin, y, '#ccc', 1) + y -= 60 + + // Recipient name + ctx.text(data.recipientName, centerX, y, 18, { + font: this.registeredFonts.has('Body') ? 'Body' : undefined, + align: 'center', + width: width - margin * 2 + }) + y -= 30 + + // Description + const descriptionLines = this.wrapText(data.description, width - margin * 3, 12) + for (const line of descriptionLines) { + ctx.text(line, centerX, y, 12, { + align: 'center', + width: width - margin * 2 + }) + y -= 18 + if (y < margin + 100) break + } + + y -= 40 + + // Date + ctx.text(`Date: ${data.date}`, centerX, y, 12, { + color: '#666', + align: 'center', + width: width - margin * 2 + }) + + // Authorized by (if provided) + if (data.authorizedBy) { + ctx.text(`Authorized by: ${data.authorizedBy}`, centerX, margin + 30, 10, { + color: '#666', + align: 'center', + width: width - margin * 2 + }) + } + }) + + return doc.build() + } + + /** + * Generate a label PDF (for plants, batches, etc.) + */ + generateLabel( + data: { + title: string + subtitle?: string + qrCode?: string + details: { label: string; value: string }[] + }, + options: PDFDocumentConfig = { width: 288, height: 144, margin: 18 } + ): Uint8Array { + const doc = pdf() + + // Register any custom fonts + for (const font of this.registeredFonts.values()) { + try { + const loadedFont = loadFont(font.data, font.name) + doc.registerFont(font.name, loadedFont) + } catch (error) { + console.warn(`Failed to register font ${font.name}:`, error) + } + } + + doc.page((ctx: any) => { + const { width, height, margin } = options + let y = height - margin + + // Title + ctx.text(data.title, margin, y, 14, { + font: this.registeredFonts.has('Label') ? 'Label' : undefined, + color: '#333' + }) + y -= 20 + + // Subtitle (if provided) + if (data.subtitle) { + ctx.text(data.subtitle, margin, y, 10, { color: '#666' }) + y -= 15 + } + + y -= 10 + + // Details + for (const detail of data.details) { + ctx.text(`${detail.label}: ${detail.value}`, margin, y, 9) + y -= 12 + if (y < margin) break + } + + // QR code placeholder (if provided) + if (data.qrCode) { + ctx.text(`QR: ${data.qrCode}`, margin, margin + 20, 8, { color: '#999' }) + } + }) + + return doc.build() + } + + /** + * Helper function to wrap text + */ + private wrapText(text: string, maxWidth: number, fontSize: number): string[] { + const lines: string[] = [] + const words = text.split(' ') + let currentLine = '' + + // Approximate character width (default Helvetica) + const avgCharWidth = fontSize * 0.5 + + for (const word of words) { + const testLine = currentLine ? `${currentLine} ${word}` : word + const testWidth = testLine.length * avgCharWidth + + if (testWidth < maxWidth) { + currentLine = testLine + } else { + if (currentLine) lines.push(currentLine) + currentLine = word + } + } + + if (currentLine) lines.push(currentLine) + return lines.length ? lines : [''] + } +} + +export const pdfService = new PDFService() diff --git a/frontend/src/components/ui/PDFDownloadButton.tsx b/frontend/src/components/ui/PDFDownloadButton.tsx new file mode 100644 index 0000000..3b118b0 --- /dev/null +++ b/frontend/src/components/ui/PDFDownloadButton.tsx @@ -0,0 +1,180 @@ +/** + * PDF Download Button Component + * Reusable button for downloading PDFs + */ + +import { Button } from './button'; +import { Download, FileText, Award, Tag } from 'lucide-react'; +import { useState } from 'react'; + +type PDFType = 'text' | 'certificate' | 'label'; + +interface PDFDownloadButtonProps { + type: PDFType; + data: unknown; + filename?: string; + children?: React.ReactNode; + variant?: 'default' | 'outline' | 'secondary' | 'ghost'; + size?: 'default' | 'sm' | 'lg'; + className?: string; + onSuccess?: () => void; + onError?: (error: Error) => void; +} + +const iconMap = { + text: FileText, + certificate: Award, + label: Tag, +}; + +export function PDFDownloadButton({ + type, + data, + filename, + children, + variant = 'outline', + size = 'default', + className = '', + onSuccess, + onError, +}: PDFDownloadButtonProps) { + const [isLoading, setIsLoading] = useState(false); + const Icon = iconMap[type]; + + const handleClick = async () => { + setIsLoading(true); + try { + const { generateTextPDF, generateCertificatePDF, generateLabelPDF } = await import('@/lib/pdf'); + + switch (type) { + case 'text': + await generateTextPDF(data as any, filename); + break; + case 'certificate': + await generateCertificatePDF(data as any, filename); + break; + case 'label': + await generateLabelPDF(data as any, filename); + break; + } + onSuccess?.(); + } catch (error) { + console.error('PDF generation failed:', error); + onError?.(error as Error); + } finally { + setIsLoading(false); + } + }; + + return ( + + ); +} + +/** + * Certificate PDF Download Button + * Pre-configured for certificate generation + */ +interface CertificateDownloadButtonProps { + data: { + title: string; + recipientName: string; + description: string; + date: string; + certificateNumber: string; + authorizedBy?: string; + }; + filename?: string; + variant?: 'default' | 'outline' | 'secondary' | 'ghost'; + size?: 'default' | 'sm' | 'lg'; + className?: string; + onSuccess?: () => void; + onError?: (error: Error) => void; +} + +export function CertificateDownloadButton({ + data, + filename, + variant = 'default', + size = 'default', + className = '', + onSuccess, + onError, +}: CertificateDownloadButtonProps) { + return ( + + + Download Certificate + + ); +} + +/** + * Label PDF Download Button + * Pre-configured for label generation (plants, batches, etc.) + */ +interface LabelDownloadButtonProps { + data: { + title: string; + subtitle?: string; + qrCode?: string; + details: Array<{ label: string; value: string }>; + }; + filename?: string; + variant?: 'default' | 'outline' | 'secondary' | 'ghost'; + size?: 'default' | 'sm' | 'lg'; + className?: string; + icon?: React.ReactNode; + onSuccess?: () => void; + onError?: (error: Error) => void; +} + +export function LabelDownloadButton({ + data, + filename, + variant = 'outline', + size = 'sm', + className = '', + icon, + onSuccess, + onError, +}: LabelDownloadButtonProps) { + return ( + + {icon || } + + ); +} diff --git a/frontend/src/lib/branding.ts b/frontend/src/lib/branding.ts new file mode 100644 index 0000000..55fc447 --- /dev/null +++ b/frontend/src/lib/branding.ts @@ -0,0 +1,144 @@ +/** + * Branding API + * Handles PDF branding configuration management + */ + +import { Capacitor } from '@capacitor/core'; +import api from './api'; + +const API_BASE_URL = Capacitor.isNativePlatform() + ? 'https://veridian.runfoo.run' + : (import.meta.env.VITE_API_URL || ''); + +export interface BrandingConfig { + id: string; + name: string; + titleFont?: string; + bodyFont?: string; + labelFont?: string; + primaryColor?: string; + secondaryColor?: string; + accentColor?: string; + logoData?: string; + createdAt: string; + updatedAt: string; +} + +/** + * Get all branding configurations + */ +export async function getAllBrandings(): Promise { + const response = await api.get<{ brandings: BrandingConfig[] }>('/branding'); + return response.data.brandings; +} + +/** + * Get a specific branding configuration + */ +export async function getBranding(id: string): Promise { + const response = await api.get(`/branding/${id}`); + return response.data; +} + +/** + * Get the default branding configuration + */ +export async function getDefaultBranding(): Promise { + const response = await api.get('/branding/default'); + return response.data; +} + +/** + * Create or update a branding configuration + */ +export async function saveBranding( + id: string | undefined, + data: Partial +): Promise { + const url = id ? `/branding/${id}` : '/branding'; + const response = await api.post(url, data); + return response.data; +} + +/** + * Delete a branding configuration + */ +export async function deleteBranding(id: string): Promise<{ success: boolean; message: string }> { + const response = await api.delete<{ success: boolean; message: string }>(`/branding/${id}`); + return response.data; +} + +/** + * Register a custom font for PDF generation + */ +export async function registerFont(name: string, fontData: string): Promise<{ + success: boolean; + font: string; + message: string; +}> { + const token = localStorage.getItem('token'); + const headers: Record = { + 'Content-Type': 'application/json', + }; + if (token) { + headers.Authorization = `Bearer ${token}`; + } + + const response = await fetch(`${API_BASE_URL}/api/pdf/fonts/register`, { + method: 'POST', + headers, + body: JSON.stringify({ name, fontData }), + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.message || 'Failed to register font'); + } + + return response.json(); +} + +/** + * Get list of registered fonts + */ +export async function getRegisteredFonts(): Promise { + const response = await fetch(`${API_BASE_URL}/api/pdf/fonts`); + + if (!response.ok) { + throw new Error(`Failed to get fonts: ${response.statusText}`); + } + + const data = await response.json(); + return data.fonts || []; +} + +/** + * Convert a file to base64 + */ +export function fileToBase64(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + const result = reader.result as string; + // Remove data URL prefix (e.g., "data:font/ttf;base64,") + const base64 = result.split(',')[1]; + resolve(base64); + }; + reader.onerror = reject; + reader.readAsDataURL(file); + }); +} + +/** + * Convert an image file to base64 + */ +export function imageToBase64(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + resolve(reader.result as string); + }; + reader.onerror = reject; + reader.readAsDataURL(file); + }); +} diff --git a/frontend/src/lib/pdf.ts b/frontend/src/lib/pdf.ts new file mode 100644 index 0000000..15dc50d --- /dev/null +++ b/frontend/src/lib/pdf.ts @@ -0,0 +1,145 @@ +/** + * PDF Generation API + * Handles PDF generation using tinypdf-plus backend + */ + +import { Capacitor } from '@capacitor/core'; + +const API_BASE_URL = Capacitor.isNativePlatform() + ? 'https://veridian.runfoo.run' + : (import.meta.env.VITE_API_URL || ''); + +export interface TextPDFOptions { + content: string; + options?: { + width?: number; + height?: number; + margin?: number; + }; +} + +export interface CertificatePDFData { + title: string; + recipientName: string; + description: string; + date: string; + certificateNumber: string; + authorizedBy?: string; +} + +export interface LabelPDFData { + title: string; + subtitle?: string; + qrCode?: string; + details: Array<{ + label: string; + value: string; + }>; +} + +/** + * Helper function to download PDF blob + */ +function downloadPDF(blob: Blob, filename: string) { + const url = window.URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + window.URL.revokeObjectURL(url); +} + +/** + * Generate a simple text PDF + */ +export async function generateTextPDF(data: TextPDFOptions, filename = 'document.pdf'): Promise { + const token = localStorage.getItem('token'); + const headers: Record = { + 'Content-Type': 'application/json', + }; + if (token) { + headers.Authorization = `Bearer ${token}`; + } + + const response = await fetch(`${API_BASE_URL}/api/pdf/text`, { + method: 'POST', + headers, + body: JSON.stringify(data), + }); + + if (!response.ok) { + throw new Error(`Failed to generate PDF: ${response.statusText}`); + } + + const blob = await response.blob(); + downloadPDF(blob, filename); +} + +/** + * Generate a certificate PDF + */ +export async function generateCertificatePDF( + data: CertificatePDFData, + filename?: string +): Promise { + const token = localStorage.getItem('token'); + const headers: Record = { + 'Content-Type': 'application/json', + }; + if (token) { + headers.Authorization = `Bearer ${token}`; + } + + const response = await fetch(`${API_BASE_URL}/api/pdf/certificate`, { + method: 'POST', + headers, + body: JSON.stringify(data), + }); + + if (!response.ok) { + throw new Error(`Failed to generate certificate: ${response.statusText}`); + } + + const blob = await response.blob(); + const defaultFilename = `certificate-${data.certificateNumber}.pdf`; + downloadPDF(blob, filename || defaultFilename); +} + +/** + * Generate a label PDF (for plants, batches, etc.) + */ +export async function generateLabelPDF(data: LabelPDFData, filename = 'label.pdf'): Promise { + const token = localStorage.getItem('token'); + const headers: Record = { + 'Content-Type': 'application/json', + }; + if (token) { + headers.Authorization = `Bearer ${token}`; + } + + const response = await fetch(`${API_BASE_URL}/api/pdf/label`, { + method: 'POST', + headers, + body: JSON.stringify(data), + }); + + if (!response.ok) { + throw new Error(`Failed to generate label: ${response.statusText}`); + } + + const blob = await response.blob(); + downloadPDF(blob, filename); +} + +/** + * Check PDF service health + */ +export async function checkPDFHealth(): Promise<{ status: string; service: string; timestamp: string }> { + const response = await fetch(`${API_BASE_URL}/api/pdf/health`); + if (!response.ok) { + throw new Error(`PDF health check failed: ${response.statusText}`); + } + return response.json(); +} diff --git a/frontend/src/pages/ReportsPage.tsx b/frontend/src/pages/ReportsPage.tsx index b2b4bf8..7ed9d4a 100644 --- a/frontend/src/pages/ReportsPage.tsx +++ b/frontend/src/pages/ReportsPage.tsx @@ -1,12 +1,13 @@ import { useState, useEffect } from 'react'; -import { BarChart3, TrendingUp, Users, Leaf } from 'lucide-react'; +import { BarChart3, TrendingUp, Users, Leaf, FileText, Tag, Award, Download } from 'lucide-react'; import { analyticsApi, YieldAnalytics, TaskAnalytics } from '../lib/analyticsApi'; import { PageHeader, EmptyState, MetricCard, CardSkeleton } from '../components/ui/LinearPrimitives'; +import { CertificateDownloadButton, LabelDownloadButton } from '../components/ui/PDFDownloadButton'; export default function ReportsPage() { const [yieldData, setYieldData] = useState(null); const [taskData, setTaskData] = useState(null); - const [activeTab, setActiveTab] = useState<'yield' | 'tasks'>('yield'); + const [activeTab, setActiveTab] = useState<'yield' | 'tasks' | 'pdf'>('yield'); const [loading, setLoading] = useState(true); useEffect(() => { @@ -53,6 +54,20 @@ export default function ReportsPage() { Task Analytics + {loading ? ( @@ -217,6 +232,134 @@ export default function ReportsPage() { )} + + {activeTab === 'pdf' && ( +
+ {/* PDF Generation Options */} +
+ {/* Certificate Generation */} +
+
+
+ +
+
+

Certificate

+

Generate completion certificates

+
+
+ +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+ + {/* Label Generation */} +
+
+
+ +
+
+

Label

+

Generate plant/batch labels

+
+
+ +
+
+ + +
+
+ + +
+ +
+
+
+ + {/* PDF Service Info */} +
+
+ +
+

PDF Generation Service

+

+ Powered by tinypdf-plus with support for custom fonts and branding. + Generates PDFs server-side for consistent formatting across all devices. +

+
+
+ + Online + +
+
+
+
+ )} )}