Compare commits
No commits in common. "4bdbfc82ca21f03c5ebbf7d1a88db1e7191fdd7b" and "1837830a11c1cc3434b049fb612605b915ddcadc" have entirely different histories.
4bdbfc82ca
...
1837830a11
12 changed files with 4 additions and 1278 deletions
|
|
@ -30,7 +30,6 @@
|
|||
"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": {
|
||||
|
|
|
|||
|
|
@ -1,94 +0,0 @@
|
|||
/**
|
||||
* 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 });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -1,131 +0,0 @@
|
|||
/**
|
||||
* 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()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
|
@ -72,12 +72,6 @@ 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';
|
||||
|
|
|
|||
|
|
@ -1,143 +0,0 @@
|
|||
/**
|
||||
* 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();
|
||||
|
|
@ -1,283 +0,0 @@
|
|||
/**
|
||||
* 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()
|
||||
|
|
@ -6,8 +6,7 @@
|
|||
href="https://fonts.googleapis.com/css2?family=Lato:wght@300;400;700&family=Merriweather:wght@300;400;700;900&display=swap"
|
||||
rel="stylesheet">
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport"
|
||||
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<meta name="theme-color" content="#10b981" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
|
|
|
|||
|
|
@ -56,10 +56,7 @@ export default function Layout() {
|
|||
{/* Main Content Area */}
|
||||
<div className="flex-1 flex flex-col min-w-0 overflow-hidden relative">
|
||||
{/* Topbar - Search, Global Filters, Vitals */}
|
||||
<header
|
||||
className="h-16 flex items-center justify-between px-4 sm:px-6 lg:px-8 border-b border-[var(--color-border-subtle)] bg-[var(--color-bg-secondary)]/80 backdrop-blur-xl z-20"
|
||||
style={{ paddingTop: 'env(safe-area-inset-top)' }}
|
||||
>
|
||||
<header className="h-16 flex items-center justify-between px-4 sm:px-6 lg:px-8 border-b border-[var(--color-border-subtle)] bg-[var(--color-bg-secondary)]/80 backdrop-blur-xl z-20">
|
||||
<div className="flex items-center gap-4 flex-1">
|
||||
{/* Facility Switcher / Filter */}
|
||||
<div className="hidden md:flex items-center gap-2 px-3 py-1.5 rounded-full bg-[var(--color-bg-tertiary)] border border-[var(--color-border-subtle)] cursor-pointer hover:border-[var(--color-border-default)] transition-all">
|
||||
|
|
|
|||
|
|
@ -1,180 +0,0 @@
|
|||
/**
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,144 +0,0 @@
|
|||
/**
|
||||
* 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);
|
||||
});
|
||||
}
|
||||
|
|
@ -1,145 +0,0 @@
|
|||
/**
|
||||
* 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();
|
||||
}
|
||||
|
|
@ -1,13 +1,12 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { BarChart3, TrendingUp, Users, Leaf, FileText, Tag, Award, Download } from 'lucide-react';
|
||||
import { BarChart3, TrendingUp, Users, Leaf } 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<YieldAnalytics | null>(null);
|
||||
const [taskData, setTaskData] = useState<TaskAnalytics | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<'yield' | 'tasks' | 'pdf'>('yield');
|
||||
const [activeTab, setActiveTab] = useState<'yield' | 'tasks'>('yield');
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -54,20 +53,6 @@ export default function ReportsPage() {
|
|||
<BarChart3 size={16} />
|
||||
Task Analytics
|
||||
</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>
|
||||
|
||||
{loading ? (
|
||||
|
|
@ -232,134 +217,6 @@ export default function ReportsPage() {
|
|||
</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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue