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 <noreply@anthropic.com>
This commit is contained in:
fullsizemalt 2026-01-08 12:07:35 -08:00
parent f4def70f24
commit 4bdbfc82ca
10 changed files with 1272 additions and 2 deletions

View file

@ -30,6 +30,7 @@
"fastify-plugin": "^4.5.0", "fastify-plugin": "^4.5.0",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"sharp": "^0.33.0", "sharp": "^0.33.0",
"tinypdf-plus": "https://github.com/fullsizemalt/tinypdf-plus.git",
"zod": "^3.22.4" "zod": "^3.22.4"
}, },
"devDependencies": { "devDependencies": {

View file

@ -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 });
}
});
}

View file

@ -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()
})
})
}

View file

@ -72,6 +72,12 @@ server.register(messagingRoutes, { prefix: '/api/messaging' });
import { plantRoutes } from './routes/plants.routes'; import { plantRoutes } from './routes/plants.routes';
server.register(plantRoutes, { prefix: '/api/plants' }); 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 // Phase 10: Compliance
import { auditRoutes } from './routes/audit.routes'; import { auditRoutes } from './routes/audit.routes';
import { documentRoutes } from './routes/documents.routes'; import { documentRoutes } from './routes/documents.routes';

View file

@ -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<string, BrandingConfig> = 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<BrandingConfig>): Promise<BrandingConfig> {
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<boolean> {
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();

View file

@ -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<string, FontConfig>()
/**
* 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()

View file

@ -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 (
<Button
variant={variant}
size={size}
onClick={handleClick}
disabled={isLoading}
className={className}
>
{isLoading ? (
<>Generating...</>
) : (
<>
<Icon className="w-4 h-4" />
{children || <Download className="w-4 h-4" />}
</>
)}
</Button>
);
}
/**
* 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 (
<PDFDownloadButton
type="certificate"
data={data}
filename={filename}
variant={variant}
size={size}
className={className}
onSuccess={onSuccess}
onError={onError}
>
<Award className="w-4 h-4" />
Download Certificate
</PDFDownloadButton>
);
}
/**
* 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 (
<PDFDownloadButton
type="label"
data={data}
filename={filename}
variant={variant}
size={size}
className={className}
onSuccess={onSuccess}
onError={onError}
>
{icon || <Tag className="w-3 h-3" />}
</PDFDownloadButton>
);
}

View file

@ -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<BrandingConfig[]> {
const response = await api.get<{ brandings: BrandingConfig[] }>('/branding');
return response.data.brandings;
}
/**
* Get a specific branding configuration
*/
export async function getBranding(id: string): Promise<BrandingConfig> {
const response = await api.get<BrandingConfig>(`/branding/${id}`);
return response.data;
}
/**
* Get the default branding configuration
*/
export async function getDefaultBranding(): Promise<BrandingConfig> {
const response = await api.get<BrandingConfig>('/branding/default');
return response.data;
}
/**
* Create or update a branding configuration
*/
export async function saveBranding(
id: string | undefined,
data: Partial<BrandingConfig>
): Promise<BrandingConfig> {
const url = id ? `/branding/${id}` : '/branding';
const response = await api.post<BrandingConfig>(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<string, string> = {
'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<string[]> {
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<string> {
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<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
resolve(reader.result as string);
};
reader.onerror = reject;
reader.readAsDataURL(file);
});
}

145
frontend/src/lib/pdf.ts Normal file
View file

@ -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<void> {
const token = localStorage.getItem('token');
const headers: Record<string, string> = {
'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<void> {
const token = localStorage.getItem('token');
const headers: Record<string, string> = {
'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<void> {
const token = localStorage.getItem('token');
const headers: Record<string, string> = {
'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();
}

View file

@ -1,12 +1,13 @@
import { useState, useEffect } from 'react'; 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 { analyticsApi, YieldAnalytics, TaskAnalytics } from '../lib/analyticsApi';
import { PageHeader, EmptyState, MetricCard, CardSkeleton } from '../components/ui/LinearPrimitives'; import { PageHeader, EmptyState, MetricCard, CardSkeleton } from '../components/ui/LinearPrimitives';
import { CertificateDownloadButton, LabelDownloadButton } from '../components/ui/PDFDownloadButton';
export default function ReportsPage() { export default function ReportsPage() {
const [yieldData, setYieldData] = useState<YieldAnalytics | null>(null); const [yieldData, setYieldData] = useState<YieldAnalytics | null>(null);
const [taskData, setTaskData] = useState<TaskAnalytics | null>(null); const [taskData, setTaskData] = useState<TaskAnalytics | null>(null);
const [activeTab, setActiveTab] = useState<'yield' | 'tasks'>('yield'); const [activeTab, setActiveTab] = useState<'yield' | 'tasks' | 'pdf'>('yield');
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
useEffect(() => { useEffect(() => {
@ -53,6 +54,20 @@ export default function ReportsPage() {
<BarChart3 size={16} /> <BarChart3 size={16} />
Task Analytics Task Analytics
</button> </button>
<button
onClick={() => setActiveTab('pdf')}
className={`
px-4 py-2 rounded-md text-sm font-medium transition-colors duration-fast
flex items-center gap-2
${activeTab === 'pdf'
? 'bg-accent text-white'
: 'text-secondary hover:text-primary hover:bg-tertiary'
}
`}
>
<FileText size={16} />
PDF Generation
</button>
</div> </div>
{loading ? ( {loading ? (
@ -217,6 +232,134 @@ export default function ReportsPage() {
</div> </div>
</div> </div>
)} )}
{activeTab === 'pdf' && (
<div className="space-y-6">
{/* PDF Generation Options */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Certificate Generation */}
<div className="card p-5 space-y-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-accent/10 flex items-center justify-center">
<Award className="w-5 h-5 text-accent" />
</div>
<div>
<h3 className="text-sm font-semibold text-primary">Certificate</h3>
<p className="text-xs text-tertiary">Generate completion certificates</p>
</div>
</div>
<div className="space-y-3">
<div>
<label className="text-xs text-secondary mb-1 block">Title</label>
<input
type="text"
defaultValue="Certificate of Completion"
className="w-full px-3 py-2 text-sm border border-subtle rounded-md bg-tertiary focus:outline-none focus:ring-2 focus:ring-accent"
placeholder="Certificate title"
/>
</div>
<div>
<label className="text-xs text-secondary mb-1 block">Recipient Name</label>
<input
type="text"
className="w-full px-3 py-2 text-sm border border-subtle rounded-md bg-tertiary focus:outline-none focus:ring-2 focus:ring-accent"
placeholder="John Doe"
/>
</div>
<div>
<label className="text-xs text-secondary mb-1 block">Certificate Number</label>
<input
type="text"
className="w-full px-3 py-2 text-sm border border-subtle rounded-md bg-tertiary focus:outline-none focus:ring-2 focus:ring-accent"
placeholder="CERT-2024-001"
/>
</div>
<CertificateDownloadButton
data={{
title: 'Certificate of Completion',
recipientName: 'Jane Doe',
description: 'This certifies that Jane Doe has successfully completed the Advanced Cultivation Management course.',
date: new Date().toISOString().split('T')[0],
certificateNumber: 'CERT-2024-001',
authorizedBy: 'Veridian Academy'
}}
variant="default"
className="w-full"
/>
</div>
</div>
{/* Label Generation */}
<div className="card p-5 space-y-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-success/10 flex items-center justify-center">
<Tag className="w-5 h-5 text-success" />
</div>
<div>
<h3 className="text-sm font-semibold text-primary">Label</h3>
<p className="text-xs text-tertiary">Generate plant/batch labels</p>
</div>
</div>
<div className="space-y-3">
<div>
<label className="text-xs text-secondary mb-1 block">Label Title</label>
<input
type="text"
defaultValue="Gelato #41"
className="w-full px-3 py-2 text-sm border border-subtle rounded-md bg-tertiary focus:outline-none focus:ring-2 focus:ring-accent"
placeholder="Strain or batch name"
/>
</div>
<div>
<label className="text-xs text-secondary mb-1 block">Subtitle</label>
<input
type="text"
defaultValue="Flower - Room 3"
className="w-full px-3 py-2 text-sm border border-subtle rounded-md bg-tertiary focus:outline-none focus:ring-2 focus:ring-accent"
placeholder="Room or stage"
/>
</div>
<LabelDownloadButton
data={{
title: 'Gelato #41',
subtitle: 'Flower - Room 3',
qrCode: 'BATCH-2024-001',
details: [
{ label: 'Strain', value: 'Gelato #41' },
{ label: 'Harvest Date', value: '2024-01-15' },
{ label: 'Weight', value: '450g' }
]
}}
filename="plant-label.pdf"
variant="outline"
className="w-full"
/>
</div>
</div>
</div>
{/* PDF Service Info */}
<div className="card p-4">
<div className="flex items-start gap-3">
<FileText className="w-5 h-5 text-tertiary mt-0.5" />
<div className="flex-1">
<h4 className="text-sm font-medium text-primary">PDF Generation Service</h4>
<p className="text-xs text-tertiary mt-1">
Powered by tinypdf-plus with support for custom fonts and branding.
Generates PDFs server-side for consistent formatting across all devices.
</p>
</div>
<div className="text-right">
<span className="inline-flex items-center px-2 py-1 rounded-md bg-success/10 text-success text-xs font-medium">
Online
</span>
</div>
</div>
</div>
</div>
)}
</> </>
)} )}
</div> </div>