AMSHistoryModal.tsx 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371
  1. import { useState, useEffect } from 'react';
  2. import { useQuery } from '@tanstack/react-query';
  3. import { X, Droplets, Thermometer, TrendingUp, TrendingDown, Minus } from 'lucide-react';
  4. import {
  5. LineChart,
  6. Line,
  7. XAxis,
  8. YAxis,
  9. CartesianGrid,
  10. Tooltip,
  11. ResponsiveContainer,
  12. Legend,
  13. ReferenceLine,
  14. } from 'recharts';
  15. import { api, type AMSHistoryResponse } from '../api/client';
  16. import { useTranslation } from 'react-i18next';
  17. import { useTheme } from '../contexts/ThemeContext';
  18. interface AMSHistoryModalProps {
  19. isOpen: boolean;
  20. onClose: () => void;
  21. printerId: number;
  22. printerName: string;
  23. amsId: number;
  24. amsLabel: string;
  25. initialMode?: 'humidity' | 'temperature';
  26. thresholds?: {
  27. humidityGood: number;
  28. humidityFair: number;
  29. tempGood: number;
  30. tempFair: number;
  31. };
  32. }
  33. type TimeRange = '6h' | '24h' | '48h' | '7d';
  34. const TIME_RANGES: { value: TimeRange; label: string; hours: number }[] = [
  35. { value: '6h', label: '6h', hours: 6 },
  36. { value: '24h', label: '24h', hours: 24 },
  37. { value: '48h', label: '48h', hours: 48 },
  38. { value: '7d', label: '7d', hours: 168 },
  39. ];
  40. export function AMSHistoryModal({
  41. isOpen,
  42. onClose,
  43. printerId,
  44. printerName,
  45. amsId,
  46. amsLabel,
  47. initialMode = 'humidity',
  48. thresholds,
  49. }: AMSHistoryModalProps) {
  50. const { t } = useTranslation();
  51. const { theme } = useTheme();
  52. const [timeRange, setTimeRange] = useState<TimeRange>('24h');
  53. const [mode, setMode] = useState<'humidity' | 'temperature'>(initialMode);
  54. const isDark = theme === 'dark';
  55. // Close on Escape key
  56. useEffect(() => {
  57. if (!isOpen) return;
  58. const handleKeyDown = (e: KeyboardEvent) => {
  59. if (e.key === 'Escape') onClose();
  60. };
  61. window.addEventListener('keydown', handleKeyDown);
  62. return () => window.removeEventListener('keydown', handleKeyDown);
  63. }, [isOpen, onClose]);
  64. const hours = TIME_RANGES.find(r => r.value === timeRange)?.hours || 24;
  65. const { data, isLoading, error } = useQuery<AMSHistoryResponse>({
  66. queryKey: ['ams-history', printerId, amsId, hours],
  67. queryFn: () => api.getAMSHistory(printerId, amsId, hours),
  68. enabled: isOpen,
  69. refetchInterval: 60000, // Refresh every minute
  70. });
  71. if (!isOpen) return null;
  72. // Format data for chart
  73. const chartData = data?.data.map(point => ({
  74. time: new Date(point.recorded_at).getTime(),
  75. humidity: point.humidity,
  76. temperature: point.temperature,
  77. timeLabel: new Date(point.recorded_at).toLocaleTimeString([], {
  78. hour: '2-digit',
  79. minute: '2-digit',
  80. ...(hours > 24 ? { day: 'numeric', month: 'short' } : {}),
  81. }),
  82. })) || [];
  83. // Get thresholds
  84. const humidityGood = thresholds?.humidityGood || 40;
  85. const humidityFair = thresholds?.humidityFair || 60;
  86. const tempGood = thresholds?.tempGood || 30;
  87. const tempFair = thresholds?.tempFair || 35;
  88. // Current values (last data point)
  89. const lastPoint = chartData[chartData.length - 1];
  90. const currentHumidity = lastPoint?.humidity;
  91. const currentTemp = lastPoint?.temperature;
  92. // Trend calculation (compare first and last 20% of data)
  93. const getTrend = (values: (number | null)[]) => {
  94. const filtered = values.filter((v): v is number => v != null);
  95. if (filtered.length < 4) return 'stable';
  96. const firstQuarter = filtered.slice(0, Math.floor(filtered.length / 4));
  97. const lastQuarter = filtered.slice(-Math.floor(filtered.length / 4));
  98. const firstAvg = firstQuarter.reduce((a, b) => a + b, 0) / firstQuarter.length;
  99. const lastAvg = lastQuarter.reduce((a, b) => a + b, 0) / lastQuarter.length;
  100. const diff = lastAvg - firstAvg;
  101. if (Math.abs(diff) < 2) return 'stable';
  102. return diff > 0 ? 'up' : 'down';
  103. };
  104. const humidityTrend = getTrend(chartData.map(d => d.humidity));
  105. const tempTrend = getTrend(chartData.map(d => d.temperature));
  106. const TrendIcon = ({ trend }: { trend: string }) => {
  107. if (trend === 'up') return <TrendingUp className="w-4 h-4 text-red-400" />;
  108. if (trend === 'down') return <TrendingDown className="w-4 h-4 text-green-400" />;
  109. return <Minus className="w-4 h-4 text-gray-400 dark:text-bambu-gray" />;
  110. };
  111. // Get status color for current value
  112. const getHumidityColor = (value: number | undefined | null) => {
  113. if (value == null) return '#9ca3af';
  114. if (value <= humidityGood) return '#22a352';
  115. if (value <= humidityFair) return '#d4a017';
  116. return '#c62828';
  117. };
  118. const getTempColor = (value: number | undefined | null) => {
  119. if (value == null) return '#9ca3af';
  120. if (value <= tempGood) return '#22a352';
  121. if (value <= tempFair) return '#d4a017';
  122. return '#c62828';
  123. };
  124. // Theme-aware styles (using isDark since dark: prefix doesn't work in portals)
  125. const modalBg = isDark ? '#2d2d2d' : '#ffffff';
  126. const cardBg = isDark ? '#1d1d1d' : '#f3f4f6';
  127. const borderColor = isDark ? '#3d3d3d' : '#e5e7eb';
  128. const textPrimary = isDark ? '#ffffff' : '#111827';
  129. const textSecondary = isDark ? '#9ca3af' : '#4b5563';
  130. return (
  131. <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50" onClick={onClose}>
  132. <div
  133. className="rounded-xl w-full max-w-4xl max-h-[90vh] overflow-hidden shadow-xl"
  134. style={{ backgroundColor: modalBg }}
  135. onClick={e => e.stopPropagation()}
  136. >
  137. {/* Header */}
  138. <div
  139. className="flex items-center justify-between px-6 py-4 border-b"
  140. style={{ borderColor }}
  141. >
  142. <div>
  143. <h2 className="text-lg font-semibold" style={{ color: textPrimary }}>
  144. {amsLabel} {t('common.history', 'History')}
  145. </h2>
  146. <p className="text-sm" style={{ color: textSecondary }}>{printerName}</p>
  147. </div>
  148. <button
  149. onClick={onClose}
  150. className="p-2 rounded-lg transition-colors"
  151. style={{ color: textSecondary }}
  152. >
  153. <X className="w-5 h-5" />
  154. </button>
  155. </div>
  156. {/* Content */}
  157. <div className="p-6 space-y-6 overflow-y-auto max-h-[calc(90vh-80px)]">
  158. {/* Time Range & Mode Selector */}
  159. <div className="flex items-center justify-between">
  160. <div className="flex gap-1 rounded-lg p-1" style={{ backgroundColor: cardBg }}>
  161. <button
  162. onClick={() => setMode('humidity')}
  163. className={`flex items-center gap-2 px-3 py-1.5 text-sm rounded-md transition-colors ${
  164. mode === 'humidity' ? 'bg-blue-600 text-white' : ''
  165. }`}
  166. style={mode !== 'humidity' ? { color: textSecondary } : undefined}
  167. >
  168. <Droplets className="w-4 h-4" />
  169. {t('common.humidity', 'Humidity')}
  170. </button>
  171. <button
  172. onClick={() => setMode('temperature')}
  173. className={`flex items-center gap-2 px-3 py-1.5 text-sm rounded-md transition-colors ${
  174. mode === 'temperature' ? 'bg-orange-600 text-white' : ''
  175. }`}
  176. style={mode !== 'temperature' ? { color: textSecondary } : undefined}
  177. >
  178. <Thermometer className="w-4 h-4" />
  179. {t('common.temperature', 'Temperature')}
  180. </button>
  181. </div>
  182. <div className="flex gap-1 rounded-lg p-1" style={{ backgroundColor: cardBg }}>
  183. {TIME_RANGES.map(range => (
  184. <button
  185. key={range.value}
  186. onClick={() => setTimeRange(range.value)}
  187. className={`px-3 py-1 text-sm rounded-md transition-colors ${
  188. timeRange === range.value ? 'bg-bambu-green text-white' : ''
  189. }`}
  190. style={timeRange !== range.value ? { color: textSecondary } : undefined}
  191. >
  192. {range.label}
  193. </button>
  194. ))}
  195. </div>
  196. </div>
  197. {/* Stats Cards */}
  198. <div className="grid grid-cols-4 gap-4">
  199. {mode === 'humidity' ? (
  200. <>
  201. <div className="rounded-lg p-4" style={{ backgroundColor: cardBg }}>
  202. <p className="text-xs" style={{ color: textSecondary }}>{t('common.current', 'Current')}</p>
  203. <div className="flex items-center gap-2">
  204. <p className="text-2xl font-bold" style={{ color: getHumidityColor(currentHumidity) }}>
  205. {currentHumidity != null ? `${currentHumidity}%` : '—'}
  206. </p>
  207. <TrendIcon trend={humidityTrend} />
  208. </div>
  209. </div>
  210. <div className="rounded-lg p-4" style={{ backgroundColor: cardBg }}>
  211. <p className="text-xs" style={{ color: textSecondary }}>{t('common.average', 'Average')}</p>
  212. <p className="text-2xl font-bold" style={{ color: textPrimary }}>
  213. {data?.avg_humidity != null ? `${data.avg_humidity}%` : '—'}
  214. </p>
  215. </div>
  216. <div className="rounded-lg p-4" style={{ backgroundColor: cardBg }}>
  217. <p className="text-xs" style={{ color: textSecondary }}>{t('common.min', 'Min')}</p>
  218. <p className="text-2xl font-bold text-green-500">
  219. {data?.min_humidity != null ? `${data.min_humidity}%` : '—'}
  220. </p>
  221. </div>
  222. <div className="rounded-lg p-4" style={{ backgroundColor: cardBg }}>
  223. <p className="text-xs" style={{ color: textSecondary }}>{t('common.max', 'Max')}</p>
  224. <p className="text-2xl font-bold text-red-500">
  225. {data?.max_humidity != null ? `${data.max_humidity}%` : '—'}
  226. </p>
  227. </div>
  228. </>
  229. ) : (
  230. <>
  231. <div className="rounded-lg p-4" style={{ backgroundColor: cardBg }}>
  232. <p className="text-xs" style={{ color: textSecondary }}>{t('common.current', 'Current')}</p>
  233. <div className="flex items-center gap-2">
  234. <p className="text-2xl font-bold" style={{ color: getTempColor(currentTemp) }}>
  235. {currentTemp != null ? `${currentTemp}°C` : '—'}
  236. </p>
  237. <TrendIcon trend={tempTrend} />
  238. </div>
  239. </div>
  240. <div className="rounded-lg p-4" style={{ backgroundColor: cardBg }}>
  241. <p className="text-xs" style={{ color: textSecondary }}>{t('common.average', 'Average')}</p>
  242. <p className="text-2xl font-bold" style={{ color: textPrimary }}>
  243. {data?.avg_temperature != null ? `${data.avg_temperature}°C` : '—'}
  244. </p>
  245. </div>
  246. <div className="rounded-lg p-4" style={{ backgroundColor: cardBg }}>
  247. <p className="text-xs" style={{ color: textSecondary }}>{t('common.min', 'Min')}</p>
  248. <p className="text-2xl font-bold text-blue-500">
  249. {data?.min_temperature != null ? `${data.min_temperature}°C` : '—'}
  250. </p>
  251. </div>
  252. <div className="rounded-lg p-4" style={{ backgroundColor: cardBg }}>
  253. <p className="text-xs" style={{ color: textSecondary }}>{t('common.max', 'Max')}</p>
  254. <p className="text-2xl font-bold text-red-500">
  255. {data?.max_temperature != null ? `${data.max_temperature}°C` : '—'}
  256. </p>
  257. </div>
  258. </>
  259. )}
  260. </div>
  261. {/* Chart */}
  262. <div className="rounded-lg p-4" style={{ backgroundColor: cardBg }}>
  263. {isLoading ? (
  264. <div className="h-[300px] flex items-center justify-center" style={{ color: textSecondary }}>
  265. {t('common.loading', 'Loading...')}
  266. </div>
  267. ) : error ? (
  268. <div className="h-[300px] flex items-center justify-center text-red-500">
  269. {t('common.error', 'Error loading data')}
  270. </div>
  271. ) : chartData.length === 0 ? (
  272. <div className="h-[300px] flex items-center justify-center" style={{ color: textSecondary }}>
  273. {t('common.noData', 'No data available for this time range')}
  274. </div>
  275. ) : (
  276. <ResponsiveContainer width="100%" height={300}>
  277. <LineChart data={chartData}>
  278. <CartesianGrid strokeDasharray="3 3" stroke={isDark ? '#3d3d3d' : '#e5e7eb'} />
  279. <XAxis
  280. dataKey="time"
  281. type="number"
  282. domain={['dataMin', 'dataMax']}
  283. tickFormatter={(ts) => {
  284. const date = new Date(ts);
  285. if (hours > 24) {
  286. return date.toLocaleDateString([], { day: 'numeric', month: 'short' });
  287. }
  288. return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
  289. }}
  290. stroke={isDark ? '#9ca3af' : '#6b7280'}
  291. tick={{ fontSize: 12 }}
  292. />
  293. <YAxis
  294. stroke={isDark ? '#9ca3af' : '#6b7280'}
  295. tick={{ fontSize: 12 }}
  296. domain={mode === 'humidity' ? [0, 100] : ['auto', 'auto']}
  297. tickFormatter={(value) => mode === 'humidity' ? `${value}%` : `${value}°C`}
  298. />
  299. <Tooltip
  300. contentStyle={{
  301. backgroundColor: isDark ? '#2d2d2d' : '#ffffff',
  302. border: `1px solid ${isDark ? '#3d3d3d' : '#e5e7eb'}`,
  303. borderRadius: '8px',
  304. color: isDark ? '#fff' : '#000',
  305. }}
  306. labelFormatter={(ts) => new Date(ts).toLocaleString()}
  307. formatter={(value: number) => [
  308. mode === 'humidity' ? `${value}%` : `${value}°C`,
  309. mode === 'humidity' ? 'Humidity' : 'Temperature'
  310. ]}
  311. />
  312. <Legend />
  313. {/* Threshold lines */}
  314. {mode === 'humidity' ? (
  315. <>
  316. <ReferenceLine y={humidityGood} stroke="#22a352" strokeDasharray="5 5" label={{ value: 'Good', fill: '#22a352', fontSize: 10 }} />
  317. <ReferenceLine y={humidityFair} stroke="#d4a017" strokeDasharray="5 5" label={{ value: 'Fair', fill: '#d4a017', fontSize: 10 }} />
  318. </>
  319. ) : (
  320. <>
  321. <ReferenceLine y={tempGood} stroke="#22a352" strokeDasharray="5 5" label={{ value: 'Good', fill: '#22a352', fontSize: 10 }} />
  322. <ReferenceLine y={tempFair} stroke="#d4a017" strokeDasharray="5 5" label={{ value: 'Fair', fill: '#d4a017', fontSize: 10 }} />
  323. </>
  324. )}
  325. <Line
  326. type="monotone"
  327. dataKey={mode}
  328. name={mode === 'humidity' ? 'Humidity' : 'Temperature'}
  329. stroke={mode === 'humidity' ? '#3b82f6' : '#f97316'}
  330. strokeWidth={2}
  331. dot={false}
  332. activeDot={{ r: 4 }}
  333. />
  334. </LineChart>
  335. </ResponsiveContainer>
  336. )}
  337. </div>
  338. {/* Info */}
  339. <div className="text-xs text-center" style={{ color: textSecondary }}>
  340. {t('amsHistory.recordingInfo', 'Data is recorded every 5 minutes while the printer is connected')}
  341. </div>
  342. </div>
  343. </div>
  344. </div>
  345. );
  346. }