ca-grow-ops-manager/frontend/src/pages/EnvironmentReportPage.tsx
fullsizemalt 41dcdce993
Some checks are pending
Test / backend-test (push) Waiting to run
Test / frontend-test (push) Waiting to run
feat: Improve Pulse analytics and Environment Report theming for light/dark mode
2026-01-06 12:12:43 -08:00

949 lines
63 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState, useEffect, useRef } from 'react';
import { motion } from 'framer-motion';
import {
FileText, Download, Printer, Calendar, Thermometer, Droplets, Wind,
AlertTriangle, CheckCircle, TrendingUp, Clock, Activity, ChevronDown, Loader2
} from 'lucide-react';
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Area, AreaChart, ReferenceLine, BarChart, Bar } from 'recharts';
import { jsPDF } from 'jspdf';
import html2canvas from 'html2canvas';
import api from '../lib/api';
interface ReportData {
period: { start: string; end: string };
devices: {
id: string;
name: string;
readings: number;
stats: {
temperature: { min: number; max: number; avg: number };
humidity: { min: number; max: number; avg: number };
vpd: { min: number; max: number; avg: number };
};
}[];
alerts: {
total: number;
resolved: number;
byType: { type: string; count: number }[];
};
history: {
timestamp: string;
temperature: number;
humidity: number;
vpd: number;
}[];
hourlyBreakdown: {
hour: string;
avgTemp: number;
avgHumidity: number;
avgVpd: number;
}[];
alertAnalytics?: {
summary: {
total: number;
resolved: number;
acknowledged: number;
unresolved: number;
resolutionRate: string | number;
};
responseTimes: {
avgMinutes: string | null;
minMinutes: string | null;
maxMinutes: string | null;
};
resolutionTimes: {
avgMinutes: string | null;
minMinutes: string | null;
maxMinutes: string | null;
};
byType: {
type: string;
count: number;
resolved: number;
avgResolutionMin: string | null;
}[];
recentAlerts: {
id: string;
type: string;
severity: string;
message: string;
value: number;
threshold: number;
createdAt: string;
acknowledgedAt: string | null;
resolvedAt: string | null;
responseTimeMin: number | null;
resolutionTimeMin: number | null;
}[];
};
}
export default function EnvironmentReportPage() {
const [reportData, setReportData] = useState<ReportData | null>(null);
const [loading, setLoading] = useState(true);
const [pdfExporting, setPdfExporting] = useState(false);
const [dateRange, setDateRange] = useState<'24h' | '7d' | '30d'>('24h');
const reportRef = useRef<HTMLDivElement>(null);
// Define light theme chart colors for the report
const reportTheme = {
background: '#ffffff',
text: '#000000',
grid: '#e2e8f0',
temp: '#dc2626', // Red-600
humidity: '#2563eb', // Blue-600
vpd: '#9333ea', // Purple-600
success: '#16a34a', // Green-600
};
useEffect(() => {
fetchReportData();
}, [dateRange]);
async function fetchReportData() {
setLoading(true);
try {
// Fetch Pulse readings and history
const [readingsRes, devicesRes] = await Promise.all([
api.get('/pulse/readings'),
api.get('/pulse/devices')
]);
const devices = devicesRes.data.devices || [];
const readings = readingsRes.data.readings || [];
// Get history for first device (or all)
const hours = dateRange === '24h' ? 24 : dateRange === '7d' ? 168 : 720;
let history: any[] = [];
if (devices.length > 0) {
try {
const historyRes = await api.get(`/pulse/devices/${devices[0].id}/history?hours=${hours}`);
history = (historyRes.data.readings || []).map((r: any) => ({
timestamp: new Date(r.timestamp).toLocaleString([], {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
}),
temperature: r.temperature,
humidity: r.humidity,
vpd: r.vpd
}));
} catch {
// Use current readings as fallback
}
}
// Calculate stats
const deviceStats = readings.map((r: any) => ({
id: r.deviceId,
name: r.deviceName,
readings: history.length || 1,
stats: {
temperature: {
min: history.length ? Math.min(...history.map(h => h.temperature)) : r.temperature,
max: history.length ? Math.max(...history.map(h => h.temperature)) : r.temperature,
avg: history.length ? history.reduce((a, h) => a + h.temperature, 0) / history.length : r.temperature
},
humidity: {
min: history.length ? Math.min(...history.map(h => h.humidity)) : r.humidity,
max: history.length ? Math.max(...history.map(h => h.humidity)) : r.humidity,
avg: history.length ? history.reduce((a, h) => a + h.humidity, 0) / history.length : r.humidity
},
vpd: {
min: history.length ? Math.min(...history.map(h => h.vpd)) : r.vpd,
max: history.length ? Math.max(...history.map(h => h.vpd)) : r.vpd,
avg: history.length ? history.reduce((a, h) => a + h.vpd, 0) / history.length : r.vpd
}
}
}));
// Calculate hourly breakdown
const hourlyMap = new Map<string, { temps: number[]; humidities: number[]; vpds: number[] }>();
history.forEach(h => {
const hour = new Date(h.timestamp).getHours().toString().padStart(2, '0') + ':00';
if (!hourlyMap.has(hour)) {
hourlyMap.set(hour, { temps: [], humidities: [], vpds: [] });
}
const bucket = hourlyMap.get(hour)!;
bucket.temps.push(h.temperature);
bucket.humidities.push(h.humidity);
bucket.vpds.push(h.vpd);
});
const hourlyBreakdown = Array.from(hourlyMap.entries()).map(([hour, data]) => ({
hour,
avgTemp: data.temps.reduce((a, b) => a + b, 0) / data.temps.length,
avgHumidity: data.humidities.reduce((a, b) => a + b, 0) / data.humidities.length,
avgVpd: data.vpds.reduce((a, b) => a + b, 0) / data.vpds.length
})).sort((a, b) => a.hour.localeCompare(b.hour));
// Fetch alerts
let alertData = { total: 0, resolved: 0, byType: [] as any[] };
try {
const alertsRes = await api.get('/environment/alerts');
const alerts = alertsRes.data || [];
alertData = {
total: alerts.length,
resolved: alerts.filter((a: any) => a.resolvedAt).length,
byType: Object.entries(
alerts.reduce((acc: any, a: any) => {
acc[a.type] = (acc[a.type] || 0) + 1;
return acc;
}, {})
).map(([type, count]) => ({ type, count: count as number }))
};
} catch {
// Ignore
}
// Fetch alert analytics with response times
let alertAnalytics = null;
try {
const analyticsRes = await api.get(`/environment/alerts/analytics?hours=${hours}`);
alertAnalytics = analyticsRes.data;
} catch {
// Ignore
}
const now = new Date();
const startDate = new Date();
startDate.setHours(startDate.getHours() - hours);
setReportData({
period: {
start: startDate.toLocaleDateString(),
end: now.toLocaleDateString()
},
devices: deviceStats,
alerts: alertData,
history: history.slice(-100), // Limit for chart performance
hourlyBreakdown,
alertAnalytics
});
} catch (error) {
console.error('Failed to fetch report data:', error);
} finally {
setLoading(false);
}
}
function handlePrint() {
window.print();
}
async function handleExportPDF() {
if (!reportRef.current) return;
setPdfExporting(true);
try {
const element = reportRef.current;
const canvas = await html2canvas(element, {
scale: 2, // Higher resolution
useCORS: true,
logging: false,
backgroundColor: '#0f172a' // Dark background matching theme
});
const imgData = canvas.toDataURL('image/png');
const pdf = new jsPDF('p', 'mm', 'a4');
const pdfWidth = pdf.internal.pageSize.getWidth();
const pdfHeight = pdf.internal.pageSize.getHeight();
const imgWidth = pdfWidth;
const imgHeight = (canvas.height * imgWidth) / canvas.width;
let heightLeft = imgHeight;
let position = 0;
// First page
pdf.addImage(imgData, 'PNG', 0, position, imgWidth, imgHeight);
heightLeft -= pdfHeight;
// Subsequent pages
while (heightLeft >= 0) {
position = heightLeft - imgHeight;
pdf.addPage();
pdf.addImage(imgData, 'PNG', 0, position, imgWidth, imgHeight);
heightLeft -= pdfHeight;
}
pdf.save(`veridian-environment-report-${new Date().toISOString().split('T')[0]}.pdf`);
} catch (error) {
console.error('PDF export failed:', error);
} finally {
setPdfExporting(false);
}
}
const formatDate = () => {
return new Date().toLocaleDateString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric'
});
};
if (loading) {
return (
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 p-8 flex items-center justify-center">
<div className="animate-spin w-8 h-8 border-2 border-emerald-500 border-t-transparent rounded-full" />
</div>
);
}
return (
<>
{/* Print Styles */}
<style>{`
@media print {
body { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
.no-print { display: none !important; }
.print-only { display: block !important; }
.print-break { page-break-before: always; }
.bg-gradient-to-br { background: white !important; }
* { color: black !important; }
.text-white, .text-slate-300, .text-slate-400 { color: #333 !important; }
}
`}</style>
<div className="min-h-screen bg-slate-50 dark:bg-slate-950 p-8 transition-colors duration-300">
<div className="max-w-5xl mx-auto">
{/* --- WEB DASHBOARD VIEW --- */}
<div className="no-print mb-8 flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-slate-900 dark:text-white flex items-center gap-3">
<div className="p-2.5 rounded-xl bg-blue-600 shadow-lg shadow-blue-500/25">
<FileText className="w-6 h-6 text-white" />
</div>
Environment Report
</h1>
<p className="text-slate-500 dark:text-slate-400 ml-14 mt-1">
Generate and export environmental monitoring reports
</p>
</div>
<div className="flex items-center gap-3">
{/* Date Range Selector */}
<div className="relative">
<select
value={dateRange}
onChange={(e) => setDateRange(e.target.value as any)}
className="appearance-none bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl px-4 py-2.5 pr-10 text-slate-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 shadow-sm"
>
<option value="24h">Last 24 Hours</option>
<option value="7d">Last 7 Days</option>
<option value="30d">Last 30 Days</option>
</select>
<ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400 pointer-events-none" />
</div>
{/* Export Buttons */}
<button
onClick={handlePrint}
className="flex items-center gap-2 px-4 py-2.5 bg-slate-700 hover:bg-slate-600 text-white rounded-xl text-sm font-medium transition-all"
>
<Printer className="w-4 h-4" />
Print
</button>
<button
onClick={handleExportPDF}
disabled={pdfExporting}
className="flex items-center gap-2 px-4 py-2.5 bg-blue-600 hover:bg-blue-500 disabled:bg-blue-600/50 disabled:cursor-wait text-white rounded-xl text-sm font-medium transition-all shadow-lg shadow-blue-500/25"
>
{pdfExporting ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Download className="w-4 h-4" />
)}
{pdfExporting ? 'Exporting...' : 'Export PDF'}
</button>
</div>
</div>
<div className={`space-y-6 no-print transition-opacity duration-300 ${pdfExporting ? 'opacity-50 pointer-events-none grayscale' : ''}`}>
{/* Dashboard Header */}
<div className="p-8 rounded-2xl bg-white dark:bg-slate-800/50 border border-slate-200 dark:border-slate-700/50 text-center shadow-sm dark:shadow-none">
<div className="inline-flex items-center gap-2 px-4 py-1.5 bg-emerald-500/10 dark:bg-emerald-500/20 text-emerald-600 dark:text-emerald-400 rounded-full text-sm font-medium mb-4">
<Activity className="w-4 h-4" />
Environment Monitoring Report
</div>
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-2">Veridian Grow Operations</h2>
<p className="text-slate-500 dark:text-slate-400">
<Calendar className="w-4 h-4 inline mr-2" />
{reportData?.period.start} {reportData?.period.end}
</p>
<p className="text-xs text-slate-400 mt-2">Generated: {formatDate()}</p>
</div>
{/* Summary Stats */}
{/* Summary Stats */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="p-5 rounded-2xl bg-white dark:bg-slate-800/50 border border-slate-200 dark:border-slate-700/50 shadow-sm dark:shadow-none"
>
<div className="flex items-center gap-2 mb-2 text-slate-500 dark:text-slate-400">
<Thermometer className="w-5 h-5 text-red-500 dark:text-red-400" />
<span className="text-sm font-medium">Avg Temperature</span>
</div>
<p className="text-2xl md:text-3xl font-bold text-slate-900 dark:text-white">
{reportData?.devices[0]?.stats.temperature.avg.toFixed(1) || '--'}°F
</p>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-1">
Range: {reportData?.devices[0]?.stats.temperature.min.toFixed(1)}° {reportData?.devices[0]?.stats.temperature.max.toFixed(1)}°
</p>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
className="p-5 rounded-2xl bg-white dark:bg-slate-800/50 border border-slate-200 dark:border-slate-700/50 shadow-sm dark:shadow-none"
>
<div className="flex items-center gap-2 mb-2 text-slate-500 dark:text-slate-400">
<Droplets className="w-5 h-5 text-blue-500 dark:text-blue-400" />
<span className="text-sm font-medium">Avg Humidity</span>
</div>
<p className="text-2xl md:text-3xl font-bold text-slate-900 dark:text-white">
{reportData?.devices[0]?.stats.humidity.avg.toFixed(0) || '--'}%
</p>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-1">
Range: {reportData?.devices[0]?.stats.humidity.min.toFixed(0)}% {reportData?.devices[0]?.stats.humidity.max.toFixed(0)}%
</p>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
className="p-5 rounded-2xl bg-white dark:bg-slate-800/50 border border-slate-200 dark:border-slate-700/50 shadow-sm dark:shadow-none"
>
<div className="flex items-center gap-2 mb-2 text-slate-500 dark:text-slate-400">
<Wind className="w-5 h-5 text-purple-500 dark:text-purple-400" />
<span className="text-sm font-medium">Avg VPD</span>
</div>
<p className="text-2xl md:text-3xl font-bold text-slate-900 dark:text-white">
{reportData?.devices[0]?.stats.vpd.avg.toFixed(2) || '--'} kPa
</p>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-1">
Range: {reportData?.devices[0]?.stats.vpd.min.toFixed(2)} {reportData?.devices[0]?.stats.vpd.max.toFixed(2)}
</p>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
className="p-5 rounded-2xl bg-white dark:bg-slate-800/50 border border-slate-200 dark:border-slate-700/50 shadow-sm dark:shadow-none"
>
<div className="flex items-center gap-2 mb-2 text-slate-500 dark:text-slate-400">
<AlertTriangle className="w-5 h-5 text-amber-500 dark:text-amber-400" />
<span className="text-sm font-medium">Alerts</span>
</div>
<p className="text-2xl md:text-3xl font-bold text-slate-900 dark:text-white">
{reportData?.alerts.total || 0}
</p>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-1">
<CheckCircle className="w-3 h-3 inline mr-1 text-green-500 dark:text-green-400" />
{reportData?.alerts.resolved || 0} resolved
</p>
</motion.div>
</div>
{/* Temperature Trend Chart */}
<div className="p-6 rounded-2xl bg-white dark:bg-slate-800/50 border border-slate-200 dark:border-slate-700/50 shadow-sm dark:shadow-none">
<h3 className="text-lg font-semibold text-slate-900 dark:text-white mb-4 flex items-center gap-2">
<TrendingUp className="w-5 h-5 text-emerald-500 dark:text-emerald-400" />
Temperature Trend
</h3>
<div className="h-64">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={reportData?.history || []}>
<defs>
<linearGradient id="reportTempGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#ef4444" stopOpacity={0.3} />
<stop offset="95%" stopColor="#ef4444" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#334155" opacity={0.3} />
<XAxis dataKey="timestamp" stroke="#64748b" fontSize={10} tickLine={false} />
<YAxis stroke="#64748b" fontSize={10} tickLine={false} />
<Tooltip contentStyle={{ backgroundColor: '#1e293b', border: '1px solid #334155', borderRadius: '8px' }} />
<ReferenceLine y={82} stroke="#f59e0b" strokeDasharray="5 5" label={{ value: 'Max', fill: '#f59e0b', fontSize: 10 }} />
<ReferenceLine y={65} stroke="#3b82f6" strokeDasharray="5 5" label={{ value: 'Min', fill: '#3b82f6', fontSize: 10 }} />
<Area type="monotone" dataKey="temperature" stroke="#ef4444" fill="url(#reportTempGradient)" strokeWidth={2} />
</AreaChart>
</ResponsiveContainer>
</div>
</div>
{/* Humidity and VPD Charts */}
<div className="grid grid-cols-2 gap-6">
<div className="p-6 rounded-2xl bg-white dark:bg-slate-800/50 border border-slate-200 dark:border-slate-700/50 shadow-sm dark:shadow-none">
<h3 className="text-sm font-semibold text-slate-900 dark:text-white mb-4 flex items-center gap-2">
<Droplets className="w-4 h-4 text-blue-500 dark:text-blue-400" />
Humidity Trend
</h3>
<div className="h-48">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={reportData?.history || []}>
<defs>
<linearGradient id="reportHumidityGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#3b82f6" stopOpacity={0.3} />
<stop offset="95%" stopColor="#3b82f6" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#334155" opacity={0.3} />
<XAxis dataKey="timestamp" stroke="#64748b" fontSize={9} tickLine={false} />
<YAxis stroke="#64748b" fontSize={9} tickLine={false} domain={[0, 100]} />
<Tooltip contentStyle={{ backgroundColor: '#1e293b', border: '1px solid #334155', borderRadius: '8px' }} />
<Area type="monotone" dataKey="humidity" stroke="#3b82f6" fill="url(#reportHumidityGradient)" strokeWidth={2} />
</AreaChart>
</ResponsiveContainer>
</div>
</div>
<div className="p-6 rounded-2xl bg-white dark:bg-slate-800/50 border border-slate-200 dark:border-slate-700/50 shadow-sm dark:shadow-none">
<h3 className="text-sm font-semibold text-slate-900 dark:text-white mb-4 flex items-center gap-2">
<Wind className="w-4 h-4 text-purple-500 dark:text-purple-400" />
VPD Trend
</h3>
<div className="h-48">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={reportData?.history || []}>
<defs>
<linearGradient id="reportVpdGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#a855f7" stopOpacity={0.3} />
<stop offset="95%" stopColor="#a855f7" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#334155" opacity={0.3} />
<XAxis dataKey="timestamp" stroke="#64748b" fontSize={9} tickLine={false} />
<YAxis stroke="#64748b" fontSize={9} tickLine={false} domain={[0, 2]} />
<Tooltip contentStyle={{ backgroundColor: '#1e293b', border: '1px solid #334155', borderRadius: '8px' }} />
<ReferenceLine y={1.2} stroke="#f59e0b" strokeDasharray="3 3" />
<ReferenceLine y={0.8} stroke="#3b82f6" strokeDasharray="3 3" />
<Area type="monotone" dataKey="vpd" stroke="#a855f7" fill="url(#reportVpdGradient)" strokeWidth={2} />
</AreaChart>
</ResponsiveContainer>
</div>
</div>
</div>
{/* Hourly Breakdown */}
{reportData?.hourlyBreakdown && reportData.hourlyBreakdown.length > 0 && (
<div className="p-6 rounded-2xl bg-white dark:bg-slate-800/50 border border-slate-200 dark:border-slate-700/50 shadow-sm dark:shadow-none print-break">
<h3 className="text-lg font-semibold text-slate-900 dark:text-white mb-4 flex items-center gap-2">
<Clock className="w-5 h-5 text-cyan-500 dark:text-cyan-400" />
Hourly Average Temperature
</h3>
<div className="h-64">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={reportData.hourlyBreakdown}>
<CartesianGrid strokeDasharray="3 3" stroke="#334155" opacity={0.3} />
<XAxis dataKey="hour" stroke="#64748b" fontSize={10} tickLine={false} />
<YAxis stroke="#64748b" fontSize={10} tickLine={false} />
<Tooltip contentStyle={{ backgroundColor: '#1e293b', border: '1px solid #334155', borderRadius: '8px' }} />
<Bar dataKey="avgTemp" fill="#10b981" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
</div>
)}
{/* Device Summary Table */}
<div className="p-6 rounded-2xl bg-white dark:bg-slate-800/50 border border-slate-200 dark:border-slate-700/50 shadow-sm dark:shadow-none">
<h3 className="text-lg font-semibold text-slate-900 dark:text-white mb-4">Device Summary</h3>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="text-left text-xs text-slate-500 dark:text-slate-400 uppercase tracking-wider">
<th className="pb-3">Device</th>
<th className="pb-3 text-center">Readings</th>
<th className="pb-3 text-center">Temp (Avg)</th>
<th className="pb-3 text-center">Humidity (Avg)</th>
<th className="pb-3 text-center">VPD (Avg)</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-200 dark:divide-slate-700/50">
{reportData?.devices.map(device => (
<tr key={device.id} className="text-sm">
<td className="py-3 text-slate-900 dark:text-white font-medium">{device.name}</td>
<td className="py-3 text-slate-600 dark:text-slate-300 text-center">{device.readings}</td>
<td className="py-3 text-center">
<span className="text-red-500 dark:text-red-400 font-medium">{device.stats.temperature.avg.toFixed(1)}°F</span>
</td>
<td className="py-3 text-center">
<span className="text-blue-500 dark:text-blue-400 font-medium">{device.stats.humidity.avg.toFixed(0)}%</span>
</td>
<td className="py-3 text-center">
<span className="text-purple-500 dark:text-purple-400 font-medium">{device.stats.vpd.avg.toFixed(2)}</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* Alert Summary */}
{reportData?.alerts.byType && reportData.alerts.byType.length > 0 && (
<div className="p-6 rounded-2xl bg-white dark:bg-slate-800/50 border border-slate-200 dark:border-slate-700/50 shadow-sm dark:shadow-none">
<h3 className="text-lg font-semibold text-slate-900 dark:text-white mb-4 flex items-center gap-2">
<AlertTriangle className="w-5 h-5 text-amber-500 dark:text-amber-400" />
Alert Summary
</h3>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{reportData.alerts.byType.map(alert => (
<div key={alert.type} className="p-4 rounded-xl bg-slate-50 dark:bg-slate-900/50 border border-slate-200 dark:border-slate-700/50">
<p className="text-xs text-slate-500 dark:text-slate-400 mb-1">{alert.type.replace('_', ' ')}</p>
<p className="text-2xl font-bold text-slate-900 dark:text-white">{alert.count}</p>
</div>
))}
</div>
</div>
)}
{/* Response Time Analytics */}
{reportData?.alertAnalytics && (
<div className="p-6 rounded-2xl bg-white dark:bg-slate-800/50 border border-slate-200 dark:border-slate-700/50 shadow-sm dark:shadow-none print-break">
<h3 className="text-lg font-semibold text-slate-900 dark:text-white mb-6 flex items-center gap-2">
<Clock className="w-5 h-5 text-cyan-500 dark:text-cyan-400" />
Alert Response Time Analytics
</h3>
{/* KPIs */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<div className="p-4 rounded-xl bg-white dark:bg-gradient-to-br dark:from-emerald-500/20 dark:to-green-500/10 border border-emerald-200 dark:border-emerald-500/20 shadow-sm dark:shadow-none">
<p className="text-xs text-slate-500 dark:text-slate-400 mb-1">Resolution Rate</p>
<p className="text-3xl font-bold text-emerald-600 dark:text-emerald-400">
{reportData.alertAnalytics.summary.resolutionRate}%
</p>
<p className="text-xs text-slate-500 mt-1">
{reportData.alertAnalytics.summary.resolved} of {reportData.alertAnalytics.summary.total}
</p>
</div>
<div className="p-4 rounded-xl bg-white dark:bg-gradient-to-br dark:from-blue-500/20 dark:to-cyan-500/10 border border-blue-200 dark:border-blue-500/20 shadow-sm dark:shadow-none">
<p className="text-xs text-slate-500 dark:text-slate-400 mb-1">Avg Resolution Time</p>
<p className="text-3xl font-bold text-blue-600 dark:text-blue-400">
{reportData.alertAnalytics.resolutionTimes.avgMinutes || '--'}
</p>
<p className="text-xs text-slate-500 mt-1">minutes</p>
</div>
<div className="p-4 rounded-xl bg-white dark:bg-gradient-to-br dark:from-amber-500/20 dark:to-orange-500/10 border border-amber-200 dark:border-amber-500/20 shadow-sm dark:shadow-none">
<p className="text-xs text-slate-500 dark:text-slate-400 mb-1">Fastest Resolution</p>
<p className="text-3xl font-bold text-amber-600 dark:text-amber-400">
{reportData.alertAnalytics.resolutionTimes.minMinutes || '--'}
</p>
<p className="text-xs text-slate-500 mt-1">minutes</p>
</div>
<div className="p-4 rounded-xl bg-white dark:bg-gradient-to-br dark:from-red-500/20 dark:to-rose-500/10 border border-red-200 dark:border-red-500/20 shadow-sm dark:shadow-none">
<p className="text-xs text-slate-500 dark:text-slate-400 mb-1">Slowest Resolution</p>
<p className="text-3xl font-bold text-red-600 dark:text-red-400">
{reportData.alertAnalytics.resolutionTimes.maxMinutes || '--'}
</p>
<p className="text-xs text-slate-500 mt-1">minutes</p>
</div>
</div>
{/* By Alert Type */}
{reportData.alertAnalytics.byType.length > 0 && (
<div className="mb-6">
<h4 className="text-sm font-medium text-slate-500 dark:text-slate-400 mb-3">Resolution Time by Alert Type</h4>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="text-left text-xs text-slate-500 dark:text-slate-400 uppercase tracking-wider">
<th className="pb-3">Alert Type</th>
<th className="pb-3 text-center">Total</th>
<th className="pb-3 text-center">Resolved</th>
<th className="pb-3 text-center">Avg Resolution</th>
<th className="pb-3 text-center">Resolution Rate</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-200 dark:divide-slate-700/50">
{reportData.alertAnalytics.byType.map(alertType => (
<tr key={alertType.type} className="text-sm">
<td className="py-3 text-slate-900 dark:text-white font-medium">{alertType.type.replace('_', ' ')}</td>
<td className="py-3 text-slate-600 dark:text-slate-300 text-center">{alertType.count}</td>
<td className="py-3 text-center">
<span className="text-green-600 dark:text-green-400">{alertType.resolved}</span>
</td>
<td className="py-3 text-center">
<span className="text-cyan-600 dark:text-cyan-400 font-medium">
{alertType.avgResolutionMin ? `${alertType.avgResolutionMin} min` : '--'}
</span>
</td>
<td className="py-3 text-center">
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${alertType.count > 0 && alertType.resolved / alertType.count >= 0.9
? 'bg-green-100 dark:bg-green-500/20 text-green-700 dark:text-green-400'
: alertType.count > 0 && alertType.resolved / alertType.count >= 0.5
? 'bg-amber-100 dark:bg-amber-500/20 text-amber-700 dark:text-amber-400'
: 'bg-red-100 dark:bg-red-500/20 text-red-700 dark:text-red-400'
}`}>
{alertType.count > 0 ? Math.round(alertType.resolved / alertType.count * 100) : 0}%
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* Recent Alerts Timeline */}
{reportData.alertAnalytics.recentAlerts.length > 0 && (
<div>
<h4 className="text-sm font-medium text-slate-500 dark:text-slate-400 mb-3">Recent Alert Timeline</h4>
<div className="space-y-2">
{reportData.alertAnalytics.recentAlerts.slice(0, 5).map(alert => (
<div
key={alert.id}
className={`p-4 rounded-xl border ${alert.resolvedAt
? 'bg-green-50 dark:bg-green-500/5 border-green-200 dark:border-green-500/20'
: 'bg-amber-50 dark:bg-amber-500/5 border-amber-200 dark:border-amber-500/20'
}`}
>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<span className={`px-2 py-0.5 rounded text-xs font-medium ${alert.severity === 'CRITICAL' ? 'bg-red-100 dark:bg-red-500/20 text-red-700 dark:text-red-400' :
alert.severity === 'WARNING' ? 'bg-amber-100 dark:bg-amber-500/20 text-amber-700 dark:text-amber-400' :
'bg-blue-100 dark:bg-blue-500/20 text-blue-700 dark:text-blue-400'
}`}>
{alert.severity}
</span>
<span className="text-sm text-slate-900 dark:text-white font-medium">{alert.type.replace('_', ' ')}</span>
</div>
<span className={`flex items-center gap-1.5 text-xs font-medium ${alert.resolvedAt ? 'text-green-600 dark:text-green-400' : 'text-amber-600 dark:text-amber-400'
}`}>
<CheckCircle className="w-3 h-3" />
{alert.resolvedAt ? 'Resolved' : 'Pending'}
</span>
</div>
<p className="text-sm text-slate-600 dark:text-slate-300 mb-2">{alert.message}</p>
<div className="flex items-center gap-4 text-xs text-slate-500">
<span>
Triggered: {new Date(alert.createdAt).toLocaleString()}
</span>
{alert.resolutionTimeMin && (
<span className="text-cyan-600 dark:text-cyan-400">
Return to Normal: {alert.resolutionTimeMin.toFixed(1)} min
</span>
)}
</div>
</div>
))}
</div>
</div>
)}
</div>
)}
{/* Footer */}
<div className="text-center py-6 text-xs text-slate-500 border-t border-slate-200 dark:border-slate-700/50">
<p>Veridian Grow Operations Manager Environment Monitoring Report</p>
<p className="mt-1">Generated automatically Data subject to sensor accuracy</p>
</div>
</div>
{/* --- CLEAN DOCUMENT LAYOUT (For PDF Generation) --- */}
{/* Wrapper for off-screen positioning - MUST be visible for html2canvas */}
<div
className="fixed top-0"
style={{
left: pdfExporting ? '0' : '-9999px',
zIndex: -50,
visibility: 'visible',
width: '210mm'
}}
>
<div
ref={reportRef}
className="bg-white text-black p-12 mx-auto shadow-2xl"
style={{
width: '210mm', // A4 Width
minHeight: '297mm', // A4 Height
fontFamily: '"Inter", "Helvetica Neue", Arial, sans-serif'
}}
>
{/* Document Header */}
<div className="border-b-2 border-slate-900 pb-6 mb-8 flex justify-between items-end">
<div>
<h1 className="text-4xl font-bold tracking-tight text-slate-900 mb-2">Environment Report</h1>
<p className="text-slate-500 font-medium">Veridian Grow Operations</p>
</div>
<div className="text-right">
<p className="text-sm text-slate-500">Report Period</p>
<p className="font-semibold text-slate-900">{reportData?.period.start} {reportData?.period.end}</p>
<p className="text-xs text-slate-400 mt-1">Generated: {new Date().toLocaleDateString()}</p>
</div>
</div>
{/* Executive Summary Table */}
<div className="mb-10">
<h2 className="text-lg font-bold uppercase tracking-wider text-slate-700 mb-4 border-l-4 border-slate-900 pl-3">
Executive Summary
</h2>
<div className="grid grid-cols-4 gap-6 mb-6">
<div className="p-4 bg-slate-50 border border-slate-200 rounded">
<span className="block text-xs font-semibold uppercase text-slate-500 mb-1">Avg Temperature</span>
<span className="block text-3xl font-bold text-slate-900">{reportData?.devices[0]?.stats.temperature.avg.toFixed(1)}°F</span>
<span className="text-xs text-slate-500">Min: {reportData?.devices[0]?.stats.temperature.min.toFixed(1)}° / Max: {reportData?.devices[0]?.stats.temperature.max.toFixed(1)}°</span>
</div>
<div className="p-4 bg-slate-50 border border-slate-200 rounded">
<span className="block text-xs font-semibold uppercase text-slate-500 mb-1">Avg Humidity</span>
<span className="block text-3xl font-bold text-slate-900">{reportData?.devices[0]?.stats.humidity.avg.toFixed(0)}%</span>
<span className="text-xs text-slate-500">Min: {reportData?.devices[0]?.stats.humidity.min.toFixed(0)}% / Max: {reportData?.devices[0]?.stats.humidity.max.toFixed(0)}%</span>
</div>
<div className="p-4 bg-slate-50 border border-slate-200 rounded">
<span className="block text-xs font-semibold uppercase text-slate-500 mb-1">Avg VPD</span>
<span className="block text-3xl font-bold text-slate-900">{reportData?.devices[0]?.stats.vpd.avg.toFixed(2)}</span>
<span className="text-xs text-slate-500">Min: {reportData?.devices[0]?.stats.vpd.min.toFixed(2)} / Max: {reportData?.devices[0]?.stats.vpd.max.toFixed(2)}</span>
</div>
<div className="p-4 bg-slate-50 border border-slate-200 rounded">
<span className="block text-xs font-semibold uppercase text-slate-500 mb-1">Total Alerts</span>
<span className="block text-3xl font-bold text-slate-900">{reportData?.alertAnalytics?.summary.total || 0}</span>
<span className="text-xs text-slate-500">Resolution Rate: {reportData?.alertAnalytics?.summary.resolutionRate}%</span>
</div>
</div>
</div>
{/* Historical Charts (Clean Style) */}
<div className="mb-10 page-break-inside-avoid">
<h2 className="text-lg font-bold uppercase tracking-wider text-slate-700 mb-4 border-l-4 border-slate-900 pl-3">
Environmental Trends
</h2>
<div className="space-y-8">
<div className="h-64 w-full border border-slate-200 p-4 rounded bg-white">
<h3 className="text-sm font-semibold text-slate-600 mb-2 text-center">Temperature History (°F)</h3>
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={reportData?.history || []}>
<CartesianGrid strokeDasharray="3 3" stroke={reportTheme.grid} vertical={false} />
<XAxis dataKey="timestamp" stroke={reportTheme.text} fontSize={10} tickLine={false} tickFormatter={(val) => val.split(',')[1]} />
<YAxis stroke={reportTheme.text} fontSize={10} tickLine={false} domain={['auto', 'auto']} />
<Area type="monotone" dataKey="temperature" stroke={reportTheme.temp} fill={reportTheme.temp} fillOpacity={0.1} strokeWidth={2} isAnimationActive={false} />
</AreaChart>
</ResponsiveContainer>
</div>
<div className="grid grid-cols-2 gap-6">
<div className="h-48 w-full border border-slate-200 p-4 rounded bg-white">
<h3 className="text-sm font-semibold text-slate-600 mb-2 text-center">Humidity History (%)</h3>
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={reportData?.history || []}>
<CartesianGrid strokeDasharray="3 3" stroke={reportTheme.grid} vertical={false} />
<XAxis dataKey="timestamp" stroke={reportTheme.text} fontSize={9} tick={false} />
<YAxis stroke={reportTheme.text} fontSize={9} tickLine={false} domain={[0, 100]} />
<Area type="monotone" dataKey="humidity" stroke={reportTheme.humidity} fill={reportTheme.humidity} fillOpacity={0.1} strokeWidth={2} isAnimationActive={false} />
</AreaChart>
</ResponsiveContainer>
</div>
<div className="h-48 w-full border border-slate-200 p-4 rounded bg-white">
<h3 className="text-sm font-semibold text-slate-600 mb-2 text-center">VPD History (kPa)</h3>
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={reportData?.history || []}>
<CartesianGrid strokeDasharray="3 3" stroke={reportTheme.grid} vertical={false} />
<XAxis dataKey="timestamp" stroke={reportTheme.text} fontSize={9} tick={false} />
<YAxis stroke={reportTheme.text} fontSize={9} tickLine={false} domain={[0, 2.5]} />
<Area type="monotone" dataKey="vpd" stroke={reportTheme.vpd} fill={reportTheme.vpd} fillOpacity={0.1} strokeWidth={2} isAnimationActive={false} />
</AreaChart>
</ResponsiveContainer>
</div>
</div>
</div>
</div>
{/* Data Tables Section */}
<div className="mb-8">
<h2 className="text-lg font-bold uppercase tracking-wider text-slate-700 mb-4 border-l-4 border-slate-900 pl-3">
Alert & Intervention Analysis
</h2>
{/* KPI Table */}
<table className="w-full text-sm text-left border border-slate-200 mb-6">
<thead className="bg-slate-100 text-slate-700 font-semibold uppercase text-xs">
<tr>
<th className="px-4 py-3 border-b border-slate-200">Metric</th>
<th className="px-4 py-3 border-b border-slate-200">Value</th>
<th className="px-4 py-3 border-b border-slate-200">Notes</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-200">
<tr>
<td className="px-4 py-2 font-medium">Avg Resolution Time</td>
<td className="px-4 py-2">{reportData?.alertAnalytics?.resolutionTimes.avgMinutes || '--'} min</td>
<td className="px-4 py-2 text-slate-500">Average time to return to normal range</td>
</tr>
<tr>
<td className="px-4 py-2 font-medium">Resolution Rate</td>
<td className="px-4 py-2">{reportData?.alertAnalytics?.summary.resolutionRate}%</td>
<td className="px-4 py-2 text-slate-500">{reportData?.alertAnalytics?.summary.resolved} resolved out of {reportData?.alertAnalytics?.summary.total} total</td>
</tr>
<tr>
<td className="px-4 py-2 font-medium">Slowest Response</td>
<td className="px-4 py-2">{reportData?.alertAnalytics?.resolutionTimes.maxMinutes || '--'} min</td>
<td className="px-4 py-2 text-slate-500">Max deviation duration</td>
</tr>
</tbody>
</table>
{/* Recent Alerts List */}
{reportData?.alertAnalytics?.recentAlerts && reportData.alertAnalytics.recentAlerts.length > 0 ? (
<table className="w-full text-xs text-left border border-slate-200 dark:border-slate-700">
<thead className="bg-slate-800 text-white font-semibold uppercase dark:bg-slate-900">
<tr>
<th className="px-4 py-2">Time</th>
<th className="px-4 py-2">Severity</th>
<th className="px-4 py-2">Alert Type</th>
<th className="px-4 py-2">Message</th>
<th className="px-4 py-2 text-right">Duration</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-200 dark:divide-slate-700/50">
{reportData.alertAnalytics.recentAlerts.slice(0, 15).map((alert, idx) => (
<tr key={alert.id} className={idx % 2 === 0 ? 'bg-white dark:bg-slate-800/50' : 'bg-slate-50 dark:bg-slate-800/30'}>
<td className="px-4 py-2 whitespace-nowrap text-slate-600 dark:text-slate-400">
{new Date(alert.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', month: 'short', day: 'numeric' })}
</td>
<td className="px-4 py-2">
<span className={`px-2 py-0.5 rounded-full font-bold text-[10px] ${alert.severity === 'CRITICAL' ? 'bg-red-100 text-red-800 dark:bg-red-500/20 dark:text-red-300' :
alert.severity === 'WARNING' ? 'bg-amber-100 text-amber-800 dark:bg-amber-500/20 dark:text-amber-300' :
'bg-blue-100 text-blue-800 dark:bg-blue-500/20 dark:text-blue-300'
}`}>
{alert.severity}
</span>
</td>
<td className="px-4 py-2 font-medium text-slate-900 dark:text-white">{alert.type.replace('_', ' ')}</td>
<td className="px-4 py-2 text-slate-600 dark:text-slate-400 truncate max-w-xs">{alert.message}</td>
<td className="px-4 py-2 text-right font-mono text-slate-700 dark:text-slate-300">
{alert.resolutionTimeMin ? `${alert.resolutionTimeMin.toFixed(1)}m` : '-'}
</td>
</tr>
))}
</tbody>
</table>
) : (
<p className="text-slate-500 dark:text-slate-400 italic text-sm p-4 border border-slate-200 dark:border-slate-700 rounded">No recent alerts found for this period.</p>
)}
</div>
{/* Footer */}
<div className="mt-12 pt-6 border-t border-slate-200 text-center text-xs text-slate-400">
<p>© {new Date().getFullYear()} Veridian Systems Generated via Environment Operations Manager</p>
</div>
</div>
</div>
</div>
</div>
</>
);
}