949 lines
63 KiB
TypeScript
949 lines
63 KiB
TypeScript
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>
|
||
</>
|
||
);
|
||
}
|