StatsPage.tsx 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605
  1. import { useQuery } from '@tanstack/react-query';
  2. import { useState, useEffect } from 'react';
  3. import {
  4. Package,
  5. Clock,
  6. CheckCircle,
  7. XCircle,
  8. DollarSign,
  9. Printer,
  10. Target,
  11. Zap,
  12. AlertTriangle,
  13. TrendingDown,
  14. FileSpreadsheet,
  15. FileText,
  16. Loader2,
  17. Eye,
  18. RotateCcw,
  19. } from 'lucide-react';
  20. import { Button } from '../components/Button';
  21. import { useToast } from '../contexts/ToastContext';
  22. import { api } from '../api/client';
  23. import { PrintCalendar } from '../components/PrintCalendar';
  24. import { FilamentTrends } from '../components/FilamentTrends';
  25. import { Dashboard, type DashboardWidget } from '../components/Dashboard';
  26. // Widget Components
  27. function QuickStatsWidget({
  28. stats,
  29. currency,
  30. }: {
  31. stats: {
  32. total_prints: number;
  33. successful_prints: number;
  34. failed_prints: number;
  35. total_print_time_hours: number;
  36. total_filament_grams: number;
  37. total_cost: number;
  38. total_energy_kwh: number;
  39. total_energy_cost: number;
  40. } | undefined;
  41. currency: string;
  42. }) {
  43. return (
  44. <div className="grid grid-cols-2 sm:grid-cols-3 gap-4">
  45. <div className="flex items-start gap-3">
  46. <div className="p-2 rounded-lg bg-bambu-dark text-bambu-green">
  47. <Package className="w-5 h-5" />
  48. </div>
  49. <div>
  50. <p className="text-xs text-bambu-gray">Total Prints</p>
  51. <p className="text-xl font-bold text-white">{stats?.total_prints || 0}</p>
  52. </div>
  53. </div>
  54. <div className="flex items-start gap-3">
  55. <div className="p-2 rounded-lg bg-bambu-dark text-blue-400">
  56. <Clock className="w-5 h-5" />
  57. </div>
  58. <div>
  59. <p className="text-xs text-bambu-gray">Print Time</p>
  60. <p className="text-xl font-bold text-white">{stats?.total_print_time_hours.toFixed(1) || 0}h</p>
  61. </div>
  62. </div>
  63. <div className="flex items-start gap-3">
  64. <div className="p-2 rounded-lg bg-bambu-dark text-orange-400">
  65. <Package className="w-5 h-5" />
  66. </div>
  67. <div>
  68. <p className="text-xs text-bambu-gray">Filament Used</p>
  69. <p className="text-xl font-bold text-white">{((stats?.total_filament_grams || 0) / 1000).toFixed(2)}kg</p>
  70. </div>
  71. </div>
  72. <div className="flex items-start gap-3">
  73. <div className="p-2 rounded-lg bg-bambu-dark text-green-400">
  74. <DollarSign className="w-5 h-5" />
  75. </div>
  76. <div>
  77. <p className="text-xs text-bambu-gray">Filament Cost</p>
  78. <p className="text-xl font-bold text-white">{currency} {stats?.total_cost.toFixed(2) || '0.00'}</p>
  79. </div>
  80. </div>
  81. <div className="flex items-start gap-3">
  82. <div className="p-2 rounded-lg bg-bambu-dark text-yellow-400">
  83. <Zap className="w-5 h-5" />
  84. </div>
  85. <div>
  86. <p className="text-xs text-bambu-gray">Energy Used</p>
  87. <p className="text-xl font-bold text-white">{stats?.total_energy_kwh.toFixed(2) || '0.00'} kWh</p>
  88. </div>
  89. </div>
  90. <div className="flex items-start gap-3">
  91. <div className="p-2 rounded-lg bg-bambu-dark text-yellow-500">
  92. <DollarSign className="w-5 h-5" />
  93. </div>
  94. <div>
  95. <p className="text-xs text-bambu-gray">Energy Cost</p>
  96. <p className="text-xl font-bold text-white">{currency} {stats?.total_energy_cost.toFixed(2) || '0.00'}</p>
  97. </div>
  98. </div>
  99. </div>
  100. );
  101. }
  102. function SuccessRateWidget({
  103. stats,
  104. }: {
  105. stats: {
  106. total_prints: number;
  107. successful_prints: number;
  108. failed_prints: number;
  109. } | undefined;
  110. }) {
  111. const successRate = stats?.total_prints
  112. ? Math.round((stats.successful_prints / stats.total_prints) * 100)
  113. : 0;
  114. return (
  115. <div className="flex items-center gap-6">
  116. <div className="relative w-28 h-28">
  117. <svg className="w-full h-full -rotate-90">
  118. <circle cx="56" cy="56" r="48" fill="none" stroke="#3d3d3d" strokeWidth="10" />
  119. <circle
  120. cx="56"
  121. cy="56"
  122. r="48"
  123. fill="none"
  124. stroke="#00ae42"
  125. strokeWidth="10"
  126. strokeLinecap="round"
  127. strokeDasharray={`${successRate * 3.02} 302`}
  128. />
  129. </svg>
  130. <div className="absolute inset-0 flex items-center justify-center">
  131. <span className="text-xl font-bold text-white">{successRate}%</span>
  132. </div>
  133. </div>
  134. <div className="space-y-2">
  135. <div className="flex items-center gap-2">
  136. <CheckCircle className="w-4 h-4 text-bambu-green" />
  137. <span className="text-sm text-bambu-gray">Successful:</span>
  138. <span className="text-sm text-white font-medium">{stats?.successful_prints || 0}</span>
  139. </div>
  140. <div className="flex items-center gap-2">
  141. <XCircle className="w-4 h-4 text-red-400" />
  142. <span className="text-sm text-bambu-gray">Failed:</span>
  143. <span className="text-sm text-white font-medium">{stats?.failed_prints || 0}</span>
  144. </div>
  145. </div>
  146. </div>
  147. );
  148. }
  149. function TimeAccuracyWidget({
  150. stats,
  151. printerMap,
  152. }: {
  153. stats: {
  154. average_time_accuracy: number | null;
  155. time_accuracy_by_printer: Record<string, number> | null;
  156. } | undefined;
  157. printerMap: Map<string, string>;
  158. }) {
  159. const accuracy = stats?.average_time_accuracy;
  160. if (accuracy === null || accuracy === undefined) {
  161. return (
  162. <div className="flex items-center justify-center h-full">
  163. <p className="text-bambu-gray text-center py-4">No time accuracy data yet</p>
  164. </div>
  165. );
  166. }
  167. // Normalize accuracy for display (100% = perfect, clamp between 50-150 for gauge)
  168. const displayValue = Math.min(150, Math.max(50, accuracy));
  169. const normalizedForGauge = ((displayValue - 50) / 100) * 100; // 50-150 -> 0-100
  170. // Color based on accuracy
  171. const getColor = (acc: number) => {
  172. if (acc >= 95 && acc <= 105) return '#00ae42'; // Green - within 5%
  173. if (acc > 105) return '#3b82f6'; // Blue - faster than expected
  174. return '#f97316'; // Orange - slower than expected
  175. };
  176. const color = getColor(accuracy);
  177. const deviation = accuracy - 100;
  178. return (
  179. <div className="flex items-center gap-6">
  180. <div className="relative w-28 h-28">
  181. <svg className="w-full h-full -rotate-90">
  182. <circle cx="56" cy="56" r="48" fill="none" stroke="#3d3d3d" strokeWidth="10" />
  183. <circle
  184. cx="56"
  185. cy="56"
  186. r="48"
  187. fill="none"
  188. stroke={color}
  189. strokeWidth="10"
  190. strokeLinecap="round"
  191. strokeDasharray={`${normalizedForGauge * 3.02} 302`}
  192. />
  193. </svg>
  194. <div className="absolute inset-0 flex flex-col items-center justify-center">
  195. <span className="text-xl font-bold text-white">{accuracy.toFixed(0)}%</span>
  196. <span className={`text-xs ${deviation >= 0 ? 'text-blue-400' : 'text-orange-400'}`}>
  197. {deviation >= 0 ? '+' : ''}{deviation.toFixed(0)}%
  198. </span>
  199. </div>
  200. </div>
  201. <div className="space-y-2 flex-1">
  202. <div className="flex items-center gap-2 text-xs text-bambu-gray">
  203. <Target className="w-3 h-3" />
  204. <span>100% = perfect estimate</span>
  205. </div>
  206. {stats?.time_accuracy_by_printer && Object.keys(stats.time_accuracy_by_printer).length > 0 && (
  207. <div className="space-y-1 mt-2">
  208. {Object.entries(stats.time_accuracy_by_printer).slice(0, 3).map(([printerId, acc]) => (
  209. <div key={printerId} className="flex items-center justify-between text-xs">
  210. <span className="text-bambu-gray truncate max-w-[100px]">
  211. {printerMap.get(printerId) || `Printer ${printerId}`}
  212. </span>
  213. <span className={`font-medium ${
  214. acc >= 95 && acc <= 105 ? 'text-bambu-green' :
  215. acc > 105 ? 'text-blue-400' : 'text-orange-400'
  216. }`}>
  217. {acc.toFixed(0)}%
  218. </span>
  219. </div>
  220. ))}
  221. </div>
  222. )}
  223. </div>
  224. </div>
  225. );
  226. }
  227. function FilamentTypesWidget({
  228. stats,
  229. }: {
  230. stats: {
  231. total_prints: number;
  232. prints_by_filament_type: Record<string, number>;
  233. } | undefined;
  234. }) {
  235. if (!stats?.prints_by_filament_type || Object.keys(stats.prints_by_filament_type).length === 0) {
  236. return <p className="text-bambu-gray text-center py-4">No filament data available</p>;
  237. }
  238. // Sort by print count descending
  239. const sortedEntries = Object.entries(stats.prints_by_filament_type).sort(
  240. ([, a], [, b]) => b - a
  241. );
  242. return (
  243. <div className="space-y-3">
  244. {sortedEntries.map(([type, count]) => {
  245. const percentage = Math.round((count / (stats.total_prints || 1)) * 100);
  246. return (
  247. <div key={type}>
  248. <div className="flex justify-between text-sm mb-1">
  249. <span className="text-white">{type}</span>
  250. <span className="text-bambu-gray">{count} prints</span>
  251. </div>
  252. <div className="h-2 bg-bambu-dark rounded-full">
  253. <div
  254. className="h-full bg-bambu-green rounded-full transition-all"
  255. style={{ width: `${percentage}%` }}
  256. />
  257. </div>
  258. </div>
  259. );
  260. })}
  261. </div>
  262. );
  263. }
  264. function PrintActivityWidget({ printDates }: { printDates: string[] }) {
  265. return <PrintCalendar printDates={printDates} months={4} />;
  266. }
  267. function PrintsByPrinterWidget({
  268. stats,
  269. printerMap,
  270. }: {
  271. stats: { prints_by_printer: Record<string, number> } | undefined;
  272. printerMap: Map<string, string>;
  273. }) {
  274. if (!stats?.prints_by_printer || Object.keys(stats.prints_by_printer).length === 0) {
  275. return <p className="text-bambu-gray text-center py-4">No printer data available</p>;
  276. }
  277. return (
  278. <div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
  279. {Object.entries(stats.prints_by_printer).map(([printerId, count]) => (
  280. <div key={printerId} className="flex items-center gap-3 p-3 bg-bambu-dark rounded-lg">
  281. <div className="p-2 bg-bambu-dark-tertiary rounded-lg">
  282. <Printer className="w-4 h-4 text-bambu-green" />
  283. </div>
  284. <div>
  285. <p className="text-white font-medium text-sm">
  286. {printerMap.get(printerId) || `Printer ${printerId}`}
  287. </p>
  288. <p className="text-xs text-bambu-gray">{count} prints</p>
  289. </div>
  290. </div>
  291. ))}
  292. </div>
  293. );
  294. }
  295. function FilamentTrendsWidget({
  296. archives,
  297. currency,
  298. }: {
  299. archives: Parameters<typeof FilamentTrends>[0]['archives'];
  300. currency: string;
  301. }) {
  302. if (!archives || archives.length === 0) {
  303. return <p className="text-bambu-gray text-center py-4">No print data available</p>;
  304. }
  305. return <FilamentTrends archives={archives} currency={currency} />;
  306. }
  307. function FailureAnalysisWidget() {
  308. const { data: analysis, isLoading } = useQuery({
  309. queryKey: ['failureAnalysis'],
  310. queryFn: () => api.getFailureAnalysis({ days: 30 }),
  311. });
  312. if (isLoading) {
  313. return (
  314. <div className="flex justify-center py-4">
  315. <Loader2 className="w-6 h-6 text-bambu-green animate-spin" />
  316. </div>
  317. );
  318. }
  319. if (!analysis || analysis.total_prints === 0) {
  320. return <p className="text-bambu-gray text-center py-4">No print data in the last 30 days</p>;
  321. }
  322. const topReasons = Object.entries(analysis.failures_by_reason)
  323. .sort(([, a], [, b]) => b - a)
  324. .slice(0, 5);
  325. return (
  326. <div className="space-y-4">
  327. {/* Summary */}
  328. <div className="flex items-center gap-4">
  329. <div className="flex items-center gap-2">
  330. <AlertTriangle className={`w-5 h-5 ${analysis.failure_rate > 20 ? 'text-red-400' : analysis.failure_rate > 10 ? 'text-yellow-400' : 'text-bambu-green'}`} />
  331. <span className="text-2xl font-bold text-white">{analysis.failure_rate.toFixed(1)}%</span>
  332. <span className="text-sm text-bambu-gray">failure rate</span>
  333. </div>
  334. <div className="text-sm text-bambu-gray">
  335. {analysis.failed_prints} / {analysis.total_prints} prints failed
  336. </div>
  337. </div>
  338. {/* Top Failure Reasons */}
  339. {topReasons.length > 0 && (
  340. <div className="space-y-2">
  341. <p className="text-xs text-bambu-gray font-medium">Top Failure Reasons</p>
  342. {topReasons.map(([reason, count]) => (
  343. <div key={reason} className="flex items-center justify-between text-sm">
  344. <span className="text-white truncate max-w-[200px]">{reason || 'Unknown'}</span>
  345. <span className="text-bambu-gray">{count}</span>
  346. </div>
  347. ))}
  348. </div>
  349. )}
  350. {/* Trend indicator */}
  351. {analysis.trend && analysis.trend.length >= 2 && (
  352. <div className="pt-2 border-t border-bambu-dark-tertiary">
  353. <div className="flex items-center gap-2 text-sm">
  354. <TrendingDown className={`w-4 h-4 ${
  355. analysis.trend[analysis.trend.length - 1].failure_rate < analysis.trend[analysis.trend.length - 2].failure_rate
  356. ? 'text-bambu-green'
  357. : 'text-red-400'
  358. }`} />
  359. <span className="text-bambu-gray">
  360. Last week: {analysis.trend[analysis.trend.length - 1].failure_rate.toFixed(1)}%
  361. </span>
  362. </div>
  363. </div>
  364. )}
  365. </div>
  366. );
  367. }
  368. export function StatsPage() {
  369. const { showToast } = useToast();
  370. const [isExporting, setIsExporting] = useState(false);
  371. const [showExportMenu, setShowExportMenu] = useState(false);
  372. const [dashboardKey, setDashboardKey] = useState(0);
  373. const [hiddenCount, setHiddenCount] = useState(0);
  374. // Read hidden count from localStorage
  375. useEffect(() => {
  376. const updateHiddenCount = () => {
  377. try {
  378. const saved = localStorage.getItem('bambusy-dashboard-layout');
  379. if (saved) {
  380. const layout = JSON.parse(saved);
  381. setHiddenCount(layout.hidden?.length || 0);
  382. }
  383. } catch {
  384. setHiddenCount(0);
  385. }
  386. };
  387. updateHiddenCount();
  388. // Listen for storage changes
  389. window.addEventListener('storage', updateHiddenCount);
  390. // Also poll for changes (since storage event doesn't fire for same-tab changes)
  391. const interval = setInterval(updateHiddenCount, 500);
  392. return () => {
  393. window.removeEventListener('storage', updateHiddenCount);
  394. clearInterval(interval);
  395. };
  396. }, [dashboardKey]);
  397. const { data: stats, isLoading } = useQuery({
  398. queryKey: ['archiveStats'],
  399. queryFn: api.getArchiveStats,
  400. });
  401. const { data: printers } = useQuery({
  402. queryKey: ['printers'],
  403. queryFn: api.getPrinters,
  404. });
  405. const { data: archives } = useQuery({
  406. queryKey: ['archives'],
  407. queryFn: () => api.getArchives(undefined, undefined, 1000, 0),
  408. });
  409. const { data: settings } = useQuery({
  410. queryKey: ['settings'],
  411. queryFn: api.getSettings,
  412. });
  413. const handleExport = async (format: 'csv' | 'xlsx') => {
  414. setShowExportMenu(false);
  415. setIsExporting(true);
  416. try {
  417. const { blob, filename } = await api.exportStats({ format, days: 90 });
  418. const url = URL.createObjectURL(blob);
  419. const a = document.createElement('a');
  420. a.href = url;
  421. a.download = filename;
  422. a.click();
  423. URL.revokeObjectURL(url);
  424. showToast('Export downloaded');
  425. } catch (err) {
  426. showToast('Export failed', 'error');
  427. } finally {
  428. setIsExporting(false);
  429. }
  430. };
  431. const currency = settings?.currency || '$';
  432. const printerMap = new Map(printers?.map((p) => [String(p.id), p.name]) || []);
  433. const printDates = archives?.map((a) => a.created_at) || [];
  434. if (isLoading) {
  435. return (
  436. <div className="p-4 md:p-8">
  437. <div className="text-center py-12 text-bambu-gray">Loading statistics...</div>
  438. </div>
  439. );
  440. }
  441. // Define dashboard widgets
  442. // Sizes: 1 = quarter (1/4), 2 = half (1/2), 4 = full width
  443. const widgets: DashboardWidget[] = [
  444. {
  445. id: 'quick-stats',
  446. title: 'Quick Stats',
  447. component: <QuickStatsWidget stats={stats} currency={currency} />,
  448. defaultSize: 2,
  449. },
  450. {
  451. id: 'success-rate',
  452. title: 'Success Rate',
  453. component: <SuccessRateWidget stats={stats} />,
  454. defaultSize: 1,
  455. },
  456. {
  457. id: 'time-accuracy',
  458. title: 'Time Accuracy',
  459. component: <TimeAccuracyWidget stats={stats} printerMap={printerMap} />,
  460. defaultSize: 1,
  461. },
  462. {
  463. id: 'filament-types',
  464. title: 'Filament Types',
  465. component: <FilamentTypesWidget stats={stats} />,
  466. defaultSize: 1,
  467. },
  468. {
  469. id: 'failure-analysis',
  470. title: 'Failure Analysis (30 days)',
  471. component: <FailureAnalysisWidget />,
  472. defaultSize: 1,
  473. },
  474. {
  475. id: 'print-activity',
  476. title: 'Print Activity',
  477. component: <PrintActivityWidget printDates={printDates} />,
  478. defaultSize: 2,
  479. },
  480. {
  481. id: 'prints-by-printer',
  482. title: 'Prints by Printer',
  483. component: <PrintsByPrinterWidget stats={stats} printerMap={printerMap} />,
  484. defaultSize: 2,
  485. },
  486. {
  487. id: 'filament-trends',
  488. title: 'Filament Usage Trends',
  489. component: <FilamentTrendsWidget archives={archives || []} currency={currency} />,
  490. defaultSize: 4,
  491. },
  492. ];
  493. return (
  494. <div className="p-4 md:p-8">
  495. <div className="flex items-center justify-between mb-6">
  496. <div>
  497. <h1 className="text-2xl font-bold text-white">Dashboard</h1>
  498. <p className="text-bambu-gray">Drag widgets to rearrange. Click the eye icon to hide.</p>
  499. </div>
  500. <div className="flex items-center gap-2">
  501. {/* Hidden widgets button - toggles panel in Dashboard */}
  502. {hiddenCount > 0 && (
  503. <Button
  504. variant="secondary"
  505. onClick={() => {
  506. // Toggle the hidden panel in Dashboard by triggering a custom event
  507. window.dispatchEvent(new CustomEvent('toggle-hidden-panel'));
  508. }}
  509. >
  510. <Eye className="w-4 h-4" />
  511. {hiddenCount} Hidden
  512. </Button>
  513. )}
  514. {/* Reset Layout */}
  515. <Button
  516. variant="secondary"
  517. onClick={() => {
  518. localStorage.removeItem('bambusy-dashboard-layout');
  519. setDashboardKey(prev => prev + 1);
  520. showToast('Layout reset');
  521. }}
  522. >
  523. <RotateCcw className="w-4 h-4" />
  524. Reset Layout
  525. </Button>
  526. {/* Export dropdown */}
  527. <div className="relative">
  528. <Button
  529. variant="secondary"
  530. onClick={() => setShowExportMenu(!showExportMenu)}
  531. disabled={isExporting}
  532. >
  533. {isExporting ? (
  534. <Loader2 className="w-4 h-4 animate-spin" />
  535. ) : (
  536. <FileSpreadsheet className="w-4 h-4" />
  537. )}
  538. Export Stats
  539. </Button>
  540. {showExportMenu && (
  541. <div className="absolute right-0 top-full mt-1 w-48 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-xl z-20">
  542. <button
  543. className="w-full px-4 py-2 text-left text-white hover:bg-bambu-dark-tertiary transition-colors flex items-center gap-2 rounded-t-lg"
  544. onClick={() => handleExport('csv')}
  545. >
  546. <FileText className="w-4 h-4" />
  547. Export as CSV
  548. </button>
  549. <button
  550. className="w-full px-4 py-2 text-left text-white hover:bg-bambu-dark-tertiary transition-colors flex items-center gap-2 rounded-b-lg"
  551. onClick={() => handleExport('xlsx')}
  552. >
  553. <FileSpreadsheet className="w-4 h-4" />
  554. Export as Excel
  555. </button>
  556. </div>
  557. )}
  558. </div>
  559. </div>
  560. </div>
  561. <Dashboard
  562. key={dashboardKey}
  563. widgets={widgets}
  564. storageKey="bambusy-dashboard-layout"
  565. hideControls
  566. />
  567. </div>
  568. );
  569. }