StatsPage.tsx 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768
  1. import { useQuery } from '@tanstack/react-query';
  2. import { useState, useEffect } from 'react';
  3. import { useTranslation } from 'react-i18next';
  4. import {
  5. Package,
  6. Clock,
  7. CheckCircle,
  8. XCircle,
  9. DollarSign,
  10. Printer,
  11. Target,
  12. Zap,
  13. AlertTriangle,
  14. TrendingDown,
  15. FileSpreadsheet,
  16. FileText,
  17. Loader2,
  18. Eye,
  19. RotateCcw,
  20. Calculator,
  21. } from 'lucide-react';
  22. import { Button } from '../components/Button';
  23. import { useToast } from '../contexts/ToastContext';
  24. import { useAuth } from '../contexts/AuthContext';
  25. import { api } from '../api/client';
  26. import { PrintCalendar } from '../components/PrintCalendar';
  27. import { FilamentTrends } from '../components/FilamentTrends';
  28. import { Dashboard, type DashboardWidget } from '../components/Dashboard';
  29. import { getCurrencySymbol } from '../utils/currency';
  30. // Widget Components
  31. function QuickStatsWidget({
  32. stats,
  33. currency,
  34. t,
  35. }: {
  36. stats: {
  37. total_prints: number;
  38. successful_prints: number;
  39. failed_prints: number;
  40. total_print_time_hours: number;
  41. total_filament_grams: number;
  42. total_cost: number;
  43. total_energy_kwh: number;
  44. total_energy_cost: number;
  45. } | undefined;
  46. currency: string;
  47. t: (key: string) => string;
  48. }) {
  49. return (
  50. <div className="grid grid-cols-2 sm:grid-cols-3 gap-4">
  51. <div className="flex items-start gap-3">
  52. <div className="p-2 rounded-lg bg-bambu-dark text-bambu-green">
  53. <Package className="w-5 h-5" />
  54. </div>
  55. <div>
  56. <p className="text-xs text-bambu-gray">{t('stats.totalPrints')}</p>
  57. <p className="text-xl font-bold text-white">{stats?.total_prints || 0}</p>
  58. </div>
  59. </div>
  60. <div className="flex items-start gap-3">
  61. <div className="p-2 rounded-lg bg-bambu-dark text-blue-400">
  62. <Clock className="w-5 h-5" />
  63. </div>
  64. <div>
  65. <p className="text-xs text-bambu-gray">{t('stats.printTime')}</p>
  66. <p className="text-xl font-bold text-white">{stats?.total_print_time_hours.toFixed(1) || 0}h</p>
  67. </div>
  68. </div>
  69. <div className="flex items-start gap-3">
  70. <div className="p-2 rounded-lg bg-bambu-dark text-orange-400">
  71. <Package className="w-5 h-5" />
  72. </div>
  73. <div>
  74. <p className="text-xs text-bambu-gray">{t('stats.filamentUsed')}</p>
  75. <p className="text-xl font-bold text-white">{((stats?.total_filament_grams || 0) / 1000).toFixed(2)}kg</p>
  76. </div>
  77. </div>
  78. <div className="flex items-start gap-3">
  79. <div className="p-2 rounded-lg bg-bambu-dark text-green-400">
  80. <DollarSign className="w-5 h-5" />
  81. </div>
  82. <div>
  83. <p className="text-xs text-bambu-gray">{t('stats.filamentCost')}</p>
  84. <p className="text-xl font-bold text-white">{currency} {stats?.total_cost.toFixed(2) || '0.00'}</p>
  85. </div>
  86. </div>
  87. <div className="flex items-start gap-3">
  88. <div className="p-2 rounded-lg bg-bambu-dark text-yellow-400">
  89. <Zap className="w-5 h-5" />
  90. </div>
  91. <div>
  92. <p className="text-xs text-bambu-gray">{t('stats.energyUsed')}</p>
  93. <p className="text-xl font-bold text-white">{stats?.total_energy_kwh.toFixed(3) || '0.000'} kWh</p>
  94. </div>
  95. </div>
  96. <div className="flex items-start gap-3">
  97. <div className="p-2 rounded-lg bg-bambu-dark text-yellow-500">
  98. <DollarSign className="w-5 h-5" />
  99. </div>
  100. <div>
  101. <p className="text-xs text-bambu-gray">{t('stats.energyCost')}</p>
  102. <p className="text-xl font-bold text-white">{currency} {stats?.total_energy_cost.toFixed(2) || '0.00'}</p>
  103. </div>
  104. </div>
  105. </div>
  106. );
  107. }
  108. function SuccessRateWidget({
  109. stats,
  110. printerMap,
  111. size = 1,
  112. t,
  113. }: {
  114. stats: {
  115. total_prints: number;
  116. successful_prints: number;
  117. failed_prints: number;
  118. prints_by_printer: Record<string, number>;
  119. } | undefined;
  120. printerMap: Map<string, string>;
  121. size?: 1 | 2 | 4;
  122. t: (key: string) => string;
  123. }) {
  124. const successRate = stats?.total_prints
  125. ? Math.round((stats.successful_prints / stats.total_prints) * 100)
  126. : 0;
  127. // Scale gauge size based on widget size
  128. const gaugeSize = size === 1 ? 112 : size === 2 ? 128 : 144;
  129. const radius = gaugeSize / 2 - 8;
  130. const circumference = radius * 2 * Math.PI;
  131. return (
  132. <div className="flex items-center gap-6">
  133. <div className="relative flex-shrink-0" style={{ width: gaugeSize, height: gaugeSize }}>
  134. <svg className="w-full h-full -rotate-90">
  135. <circle
  136. cx={gaugeSize / 2}
  137. cy={gaugeSize / 2}
  138. r={radius}
  139. fill="none"
  140. stroke="#3d3d3d"
  141. strokeWidth="10"
  142. />
  143. <circle
  144. cx={gaugeSize / 2}
  145. cy={gaugeSize / 2}
  146. r={radius}
  147. fill="none"
  148. stroke="#00ae42"
  149. strokeWidth="10"
  150. strokeLinecap="round"
  151. strokeDasharray={`${(successRate / 100) * circumference} ${circumference}`}
  152. />
  153. </svg>
  154. <div className="absolute inset-0 flex items-center justify-center">
  155. <span className={`font-bold text-white ${size >= 2 ? 'text-2xl' : 'text-xl'}`}>{successRate}%</span>
  156. </div>
  157. </div>
  158. <div className="flex-1 min-w-0">
  159. <div className="space-y-2">
  160. <div className="flex items-center gap-2">
  161. <CheckCircle className="w-4 h-4 text-status-ok flex-shrink-0" />
  162. <span className="text-sm text-bambu-gray">{t('stats.successful')}</span>
  163. <span className="text-sm text-white font-medium">{stats?.successful_prints || 0}</span>
  164. </div>
  165. <div className="flex items-center gap-2">
  166. <XCircle className="w-4 h-4 text-status-error flex-shrink-0" />
  167. <span className="text-sm text-bambu-gray">{t('stats.failed')}</span>
  168. <span className="text-sm text-white font-medium">{stats?.failed_prints || 0}</span>
  169. </div>
  170. </div>
  171. {/* Show per-printer breakdown when expanded */}
  172. {size >= 2 && stats?.prints_by_printer && Object.keys(stats.prints_by_printer).length > 0 && (
  173. <div className="mt-4 pt-4 border-t border-bambu-dark-tertiary">
  174. <p className="text-xs text-bambu-gray font-medium mb-2">{t('stats.printsByPrinter')}</p>
  175. <div className={`grid gap-x-6 gap-y-1 ${size === 4 ? 'grid-cols-3' : 'grid-cols-2'}`} style={{ width: 'fit-content' }}>
  176. {Object.entries(stats.prints_by_printer).map(([printerId, count]) => (
  177. <div key={printerId} className="flex items-center gap-3 text-sm">
  178. <span className="text-bambu-gray truncate max-w-[120px]">
  179. {printerMap.get(printerId) || `${t('common.printer')} ${printerId}`}
  180. </span>
  181. <span className="text-white font-medium">{count}</span>
  182. </div>
  183. ))}
  184. </div>
  185. </div>
  186. )}
  187. </div>
  188. </div>
  189. );
  190. }
  191. function TimeAccuracyWidget({
  192. stats,
  193. printerMap,
  194. size = 1,
  195. t,
  196. }: {
  197. stats: {
  198. average_time_accuracy: number | null;
  199. time_accuracy_by_printer: Record<string, number> | null;
  200. } | undefined;
  201. printerMap: Map<string, string>;
  202. size?: 1 | 2 | 4;
  203. t: (key: string) => string;
  204. }) {
  205. const accuracy = stats?.average_time_accuracy;
  206. if (accuracy === null || accuracy === undefined) {
  207. return (
  208. <div className="flex items-center justify-center h-full">
  209. <p className="text-bambu-gray text-center py-4">{t('stats.noTimeAccuracyData')}</p>
  210. </div>
  211. );
  212. }
  213. // Normalize accuracy for display (100% = perfect, clamp between 50-150 for gauge)
  214. const displayValue = Math.min(150, Math.max(50, accuracy));
  215. const normalizedForGauge = ((displayValue - 50) / 100) * 100; // 50-150 -> 0-100
  216. // Color based on accuracy
  217. const getColor = (acc: number) => {
  218. if (acc >= 95 && acc <= 105) return '#00ae42'; // Green - within 5%
  219. if (acc > 105) return '#3b82f6'; // Blue - faster than expected
  220. return '#f97316'; // Orange - slower than expected
  221. };
  222. const color = getColor(accuracy);
  223. const deviation = accuracy - 100;
  224. // Scale gauge size based on widget size
  225. const gaugeSize = size === 1 ? 112 : size === 2 ? 128 : 144;
  226. const radius = gaugeSize / 2 - 8;
  227. const circumference = radius * 2 * Math.PI;
  228. // Show more printers when expanded
  229. const maxPrinters = size === 1 ? 3 : size === 2 ? 6 : 999;
  230. const printerEntries = stats?.time_accuracy_by_printer
  231. ? Object.entries(stats.time_accuracy_by_printer).slice(0, maxPrinters)
  232. : [];
  233. return (
  234. <div className="flex items-center gap-6">
  235. <div className="relative flex-shrink-0" style={{ width: gaugeSize, height: gaugeSize }}>
  236. <svg className="w-full h-full -rotate-90">
  237. <circle
  238. cx={gaugeSize / 2}
  239. cy={gaugeSize / 2}
  240. r={radius}
  241. fill="none"
  242. stroke="#3d3d3d"
  243. strokeWidth="10"
  244. />
  245. <circle
  246. cx={gaugeSize / 2}
  247. cy={gaugeSize / 2}
  248. r={radius}
  249. fill="none"
  250. stroke={color}
  251. strokeWidth="10"
  252. strokeLinecap="round"
  253. strokeDasharray={`${(normalizedForGauge / 100) * circumference} ${circumference}`}
  254. />
  255. </svg>
  256. <div className="absolute inset-0 flex flex-col items-center justify-center">
  257. <span className={`font-bold text-white ${size >= 2 ? 'text-2xl' : 'text-xl'}`}>{accuracy.toFixed(0)}%</span>
  258. <span className={`text-xs ${deviation >= 0 ? 'text-blue-400' : 'text-orange-400'}`}>
  259. {deviation >= 0 ? '+' : ''}{deviation.toFixed(0)}%
  260. </span>
  261. </div>
  262. </div>
  263. <div className="flex-1 min-w-0">
  264. <div className="flex items-center gap-2 text-xs text-bambu-gray">
  265. <Target className="w-3 h-3 flex-shrink-0" />
  266. <span>{t('stats.perfectEstimate')}</span>
  267. </div>
  268. {printerEntries.length > 0 && (
  269. <div className={`mt-2 ${size === 4 ? 'grid grid-cols-3 gap-x-6 gap-y-1' : size === 2 ? 'grid grid-cols-2 gap-x-6 gap-y-1' : 'space-y-1'}`} style={{ width: 'fit-content' }}>
  270. {printerEntries.map(([printerId, acc]) => (
  271. <div key={printerId} className="flex items-center gap-2 text-xs">
  272. <span className="text-bambu-gray truncate max-w-[100px]">
  273. {printerMap.get(printerId) || `${t('common.printer')} ${printerId}`}
  274. </span>
  275. <span className={`font-medium ${
  276. acc >= 95 && acc <= 105 ? 'text-status-ok' :
  277. acc > 105 ? 'text-blue-400' : 'text-status-warning'
  278. }`}>
  279. {acc.toFixed(0)}%
  280. </span>
  281. </div>
  282. ))}
  283. </div>
  284. )}
  285. </div>
  286. </div>
  287. );
  288. }
  289. function FilamentTypesWidget({
  290. stats,
  291. size = 1,
  292. t,
  293. }: {
  294. stats: {
  295. total_prints: number;
  296. prints_by_filament_type: Record<string, number>;
  297. } | undefined;
  298. size?: 1 | 2 | 4;
  299. t: (key: string, options?: Record<string, unknown>) => string;
  300. }) {
  301. if (!stats?.prints_by_filament_type || Object.keys(stats.prints_by_filament_type).length === 0) {
  302. return <p className="text-bambu-gray text-center py-4">{t('stats.noFilamentData')}</p>;
  303. }
  304. // Sort by print count descending
  305. const sortedEntries = Object.entries(stats.prints_by_filament_type).sort(
  306. ([, a], [, b]) => b - a
  307. );
  308. // Limit entries based on size
  309. const maxEntries = size === 1 ? 5 : size === 2 ? 8 : 999;
  310. const displayEntries = sortedEntries.slice(0, maxEntries);
  311. const hasMore = sortedEntries.length > maxEntries;
  312. // Use grid layout when expanded
  313. if (size === 4 && displayEntries.length > 4) {
  314. return (
  315. <div className="grid grid-cols-2 lg:grid-cols-3 gap-4">
  316. {displayEntries.map(([type, count]) => {
  317. const percentage = Math.round((count / (stats.total_prints || 1)) * 100);
  318. return (
  319. <div key={type}>
  320. <div className="flex justify-between text-sm mb-1">
  321. <span className="text-white truncate max-w-[120px]">{type}</span>
  322. <span className="text-bambu-gray">{count}</span>
  323. </div>
  324. <div className="h-2 bg-bambu-dark rounded-full">
  325. <div
  326. className="h-full bg-bambu-green rounded-full transition-all"
  327. style={{ width: `${percentage}%` }}
  328. />
  329. </div>
  330. </div>
  331. );
  332. })}
  333. </div>
  334. );
  335. }
  336. return (
  337. <div className="space-y-3">
  338. {displayEntries.map(([type, count]) => {
  339. const percentage = Math.round((count / (stats.total_prints || 1)) * 100);
  340. return (
  341. <div key={type}>
  342. <div className="flex justify-between text-sm mb-1">
  343. <span className="text-white">{type}</span>
  344. <span className="text-bambu-gray">{count} {t('common.prints')}</span>
  345. </div>
  346. <div className="h-2 bg-bambu-dark rounded-full">
  347. <div
  348. className="h-full bg-bambu-green rounded-full transition-all"
  349. style={{ width: `${percentage}%` }}
  350. />
  351. </div>
  352. </div>
  353. );
  354. })}
  355. {hasMore && (
  356. <p className="text-xs text-bambu-gray text-center pt-1">
  357. {t('common.more', { count: sortedEntries.length - maxEntries })}
  358. </p>
  359. )}
  360. </div>
  361. );
  362. }
  363. function PrintActivityWidget({
  364. printDates,
  365. size = 2,
  366. }: {
  367. printDates: string[];
  368. size?: 1 | 2 | 4;
  369. }) {
  370. // Show more months when widget is larger - cell size auto-calculated
  371. const months = size === 1 ? 3 : size === 2 ? 6 : 12;
  372. return <PrintCalendar printDates={printDates} months={months} />;
  373. }
  374. function PrintsByPrinterWidget({
  375. stats,
  376. printerMap,
  377. t,
  378. }: {
  379. stats: { prints_by_printer: Record<string, number> } | undefined;
  380. printerMap: Map<string, string>;
  381. t: (key: string) => string;
  382. }) {
  383. if (!stats?.prints_by_printer || Object.keys(stats.prints_by_printer).length === 0) {
  384. return <p className="text-bambu-gray text-center py-4">{t('stats.noPrinterData')}</p>;
  385. }
  386. return (
  387. <div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
  388. {Object.entries(stats.prints_by_printer).map(([printerId, count]) => (
  389. <div key={printerId} className="flex items-center gap-3 p-3 bg-bambu-dark rounded-lg">
  390. <div className="p-2 bg-bambu-dark-tertiary rounded-lg">
  391. <Printer className="w-4 h-4 text-bambu-green" />
  392. </div>
  393. <div>
  394. <p className="text-white font-medium text-sm">
  395. {printerMap.get(printerId) || `${t('common.printer')} ${printerId}`}
  396. </p>
  397. <p className="text-xs text-bambu-gray">{count} {t('common.prints')}</p>
  398. </div>
  399. </div>
  400. ))}
  401. </div>
  402. );
  403. }
  404. function FilamentTrendsWidget({
  405. archives,
  406. currency,
  407. t,
  408. }: {
  409. archives: Parameters<typeof FilamentTrends>[0]['archives'];
  410. currency: string;
  411. t: (key: string) => string;
  412. }) {
  413. if (!archives || archives.length === 0) {
  414. return <p className="text-bambu-gray text-center py-4">{t('stats.noPrintData')}</p>;
  415. }
  416. return <FilamentTrends archives={archives} currency={currency} />;
  417. }
  418. function FailureAnalysisWidget({ size = 1, t }: { size?: 1 | 2 | 4; t: (key: string, options?: Record<string, unknown>) => string }) {
  419. const { data: analysis, isLoading } = useQuery({
  420. queryKey: ['failureAnalysis'],
  421. queryFn: () => api.getFailureAnalysis({ days: 30 }),
  422. });
  423. if (isLoading) {
  424. return (
  425. <div className="flex justify-center py-4">
  426. <Loader2 className="w-6 h-6 text-bambu-green animate-spin" />
  427. </div>
  428. );
  429. }
  430. if (!analysis || analysis.total_prints === 0) {
  431. return <p className="text-bambu-gray text-center py-4">{t('stats.noPrintDataLast30Days')}</p>;
  432. }
  433. // Show more reasons when expanded
  434. const maxReasons = size === 1 ? 5 : size === 2 ? 8 : 999;
  435. const allReasons = Object.entries(analysis.failures_by_reason).sort(([, a], [, b]) => b - a);
  436. const topReasons = allReasons.slice(0, maxReasons);
  437. const hasMore = allReasons.length > maxReasons;
  438. return (
  439. <div className={`${size >= 2 ? 'flex gap-8' : 'space-y-4'}`}>
  440. {/* Summary */}
  441. <div className={size >= 2 ? 'flex-shrink-0' : ''}>
  442. <div className="flex items-center gap-4">
  443. <div className="flex items-center gap-2">
  444. <AlertTriangle className={`w-5 h-5 ${analysis.failure_rate > 20 ? 'text-status-error' : analysis.failure_rate > 10 ? 'text-status-warning' : 'text-status-ok'}`} />
  445. <span className={`font-bold text-white ${size >= 2 ? 'text-3xl' : 'text-2xl'}`}>{analysis.failure_rate.toFixed(1)}%</span>
  446. </div>
  447. </div>
  448. <div className="text-sm text-bambu-gray mt-1">
  449. {t('stats.failedPrintsCount', { failed: analysis.failed_prints, total: analysis.total_prints })}
  450. </div>
  451. {/* Trend indicator */}
  452. {analysis.trend && analysis.trend.length >= 2 && (
  453. <div className={`${size >= 2 ? 'mt-4' : 'mt-2 pt-2 border-t border-bambu-dark-tertiary'}`}>
  454. <div className="flex items-center gap-2 text-sm">
  455. <TrendingDown className={`w-4 h-4 ${
  456. analysis.trend[analysis.trend.length - 1].failure_rate < analysis.trend[analysis.trend.length - 2].failure_rate
  457. ? 'text-status-ok'
  458. : 'text-status-error'
  459. }`} />
  460. <span className="text-bambu-gray">
  461. {t('stats.lastWeekRate', { rate: analysis.trend[analysis.trend.length - 1].failure_rate.toFixed(1) })}
  462. </span>
  463. </div>
  464. </div>
  465. )}
  466. </div>
  467. {/* Failure Reasons */}
  468. {topReasons.length > 0 && (
  469. <div className={`flex-1 ${size >= 2 ? 'border-l border-bambu-dark-tertiary pl-8' : 'pt-2'}`}>
  470. <p className="text-xs text-bambu-gray font-medium mb-2">
  471. {size >= 2 ? t('stats.failureReasons') : t('stats.topFailureReasons')}
  472. </p>
  473. <div className={`${size === 4 ? 'grid grid-cols-2 gap-x-6 gap-y-1' : 'space-y-1'}`}>
  474. {topReasons.map(([reason, count]) => (
  475. <div key={reason} className="flex items-center justify-between text-sm">
  476. <span className={`text-white truncate ${size === 4 ? 'max-w-[200px]' : 'max-w-[160px]'}`}>
  477. {reason || t('common.unknown')}
  478. </span>
  479. <span className="text-bambu-gray ml-2">{count}</span>
  480. </div>
  481. ))}
  482. </div>
  483. {hasMore && (
  484. <p className="text-xs text-bambu-gray mt-2">
  485. {t('common.more', { count: allReasons.length - maxReasons })}
  486. </p>
  487. )}
  488. </div>
  489. )}
  490. </div>
  491. );
  492. }
  493. export function StatsPage() {
  494. const { t } = useTranslation();
  495. const { showToast } = useToast();
  496. const { hasPermission } = useAuth();
  497. const [isExporting, setIsExporting] = useState(false);
  498. const [showExportMenu, setShowExportMenu] = useState(false);
  499. const [dashboardKey, setDashboardKey] = useState(0);
  500. const [hiddenCount, setHiddenCount] = useState(0);
  501. const [isRecalculating, setIsRecalculating] = useState(false);
  502. // Read hidden count from localStorage
  503. useEffect(() => {
  504. const updateHiddenCount = () => {
  505. try {
  506. const saved = localStorage.getItem('bambusy-dashboard-layout');
  507. if (saved) {
  508. const layout = JSON.parse(saved);
  509. setHiddenCount(layout.hidden?.length || 0);
  510. }
  511. } catch {
  512. setHiddenCount(0);
  513. }
  514. };
  515. updateHiddenCount();
  516. // Listen for storage changes
  517. window.addEventListener('storage', updateHiddenCount);
  518. // Also poll for changes (since storage event doesn't fire for same-tab changes)
  519. const interval = setInterval(updateHiddenCount, 500);
  520. return () => {
  521. window.removeEventListener('storage', updateHiddenCount);
  522. clearInterval(interval);
  523. };
  524. }, [dashboardKey]);
  525. const { data: stats, isLoading, refetch: refetchStats } = useQuery({
  526. queryKey: ['archiveStats'],
  527. queryFn: api.getArchiveStats,
  528. });
  529. const { data: printers } = useQuery({
  530. queryKey: ['printers'],
  531. queryFn: api.getPrinters,
  532. });
  533. const { data: archives } = useQuery({
  534. queryKey: ['archives'],
  535. queryFn: () => api.getArchives(undefined, undefined, 1000, 0),
  536. });
  537. const { data: settings } = useQuery({
  538. queryKey: ['settings'],
  539. queryFn: api.getSettings,
  540. });
  541. const handleExport = async (format: 'csv' | 'xlsx') => {
  542. setShowExportMenu(false);
  543. setIsExporting(true);
  544. try {
  545. const { blob, filename } = await api.exportStats({ format, days: 90 });
  546. const url = URL.createObjectURL(blob);
  547. const a = document.createElement('a');
  548. a.href = url;
  549. a.download = filename;
  550. a.click();
  551. URL.revokeObjectURL(url);
  552. showToast(t('stats.exportDownloaded'));
  553. } catch {
  554. showToast(t('stats.exportFailed'), 'error');
  555. } finally {
  556. setIsExporting(false);
  557. }
  558. };
  559. const handleRecalculateCosts = async () => {
  560. setIsRecalculating(true);
  561. try {
  562. const result = await api.recalculateCosts();
  563. await refetchStats();
  564. showToast(t('stats.recalculatedCosts', { count: result.updated }));
  565. } catch {
  566. showToast(t('stats.recalculateFailed'), 'error');
  567. } finally {
  568. setIsRecalculating(false);
  569. }
  570. };
  571. const currency = getCurrencySymbol(settings?.currency || 'USD');
  572. const printerMap = new Map(printers?.map((p) => [String(p.id), p.name]) || []);
  573. const printDates = archives?.map((a) => a.created_at) || [];
  574. if (isLoading) {
  575. return (
  576. <div className="p-4 md:p-8">
  577. <div className="text-center py-12 text-bambu-gray">{t('stats.loadingStats')}</div>
  578. </div>
  579. );
  580. }
  581. // Define dashboard widgets
  582. // Sizes: 1 = quarter (1/4), 2 = half (1/2), 4 = full width
  583. // Widgets can use render functions to receive the current size for responsive content
  584. const widgets: DashboardWidget[] = [
  585. {
  586. id: 'quick-stats',
  587. title: t('stats.quickStats'),
  588. component: <QuickStatsWidget stats={stats} currency={currency} t={t} />,
  589. defaultSize: 2,
  590. },
  591. {
  592. id: 'success-rate',
  593. title: t('stats.successRate'),
  594. component: (size) => <SuccessRateWidget stats={stats} printerMap={printerMap} size={size} t={t} />,
  595. defaultSize: 1,
  596. },
  597. {
  598. id: 'time-accuracy',
  599. title: t('stats.timeAccuracy'),
  600. component: (size) => <TimeAccuracyWidget stats={stats} printerMap={printerMap} size={size} t={t} />,
  601. defaultSize: 1,
  602. },
  603. {
  604. id: 'filament-types',
  605. title: t('stats.filamentTypes'),
  606. component: (size) => <FilamentTypesWidget stats={stats} size={size} t={t} />,
  607. defaultSize: 1,
  608. },
  609. {
  610. id: 'failure-analysis',
  611. title: t('stats.failureAnalysis'),
  612. component: (size) => <FailureAnalysisWidget size={size} t={t} />,
  613. defaultSize: 1,
  614. },
  615. {
  616. id: 'print-activity',
  617. title: t('stats.printActivity'),
  618. component: (size) => <PrintActivityWidget printDates={printDates} size={size} />,
  619. defaultSize: 2,
  620. },
  621. {
  622. id: 'prints-by-printer',
  623. title: t('stats.printsByPrinter'),
  624. component: <PrintsByPrinterWidget stats={stats} printerMap={printerMap} t={t} />,
  625. defaultSize: 2,
  626. },
  627. {
  628. id: 'filament-trends',
  629. title: t('stats.filamentTrends'),
  630. component: <FilamentTrendsWidget archives={archives || []} currency={currency} t={t} />,
  631. defaultSize: 4,
  632. },
  633. ];
  634. return (
  635. <div className="p-4 md:p-8">
  636. <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
  637. <div>
  638. <h1 className="text-2xl font-bold text-white">{t('stats.title')}</h1>
  639. <p className="text-bambu-gray">{t('stats.subtitle')}</p>
  640. </div>
  641. <div className="flex items-center gap-2 flex-wrap">
  642. {/* Hidden widgets button - toggles panel in Dashboard */}
  643. {hiddenCount > 0 && (
  644. <Button
  645. variant="secondary"
  646. onClick={() => {
  647. // Toggle the hidden panel in Dashboard by triggering a custom event
  648. window.dispatchEvent(new CustomEvent('toggle-hidden-panel'));
  649. }}
  650. >
  651. <Eye className="w-4 h-4" />
  652. {t('stats.hiddenCount', { count: hiddenCount })}
  653. </Button>
  654. )}
  655. {/* Reset Layout */}
  656. <Button
  657. variant="secondary"
  658. onClick={() => {
  659. localStorage.removeItem('bambusy-dashboard-layout');
  660. setDashboardKey(prev => prev + 1);
  661. showToast(t('stats.layoutReset'));
  662. }}
  663. disabled={!hasPermission('settings:update')}
  664. title={!hasPermission('settings:update') ? t('stats.noPermissionResetLayout') : undefined}
  665. >
  666. <RotateCcw className="w-4 h-4" />
  667. {t('stats.resetLayout')}
  668. </Button>
  669. {/* Recalculate Costs */}
  670. <Button
  671. variant="secondary"
  672. onClick={handleRecalculateCosts}
  673. disabled={isRecalculating || !hasPermission('archives:update_all')}
  674. title={!hasPermission('archives:update_all') ? t('stats.noPermissionRecalculate') : t('stats.recalculateCostsHint')}
  675. >
  676. {isRecalculating ? (
  677. <Loader2 className="w-4 h-4 animate-spin" />
  678. ) : (
  679. <Calculator className="w-4 h-4" />
  680. )}
  681. {t('stats.recalculateCosts')}
  682. </Button>
  683. {/* Export dropdown */}
  684. <div className="relative">
  685. <Button
  686. variant="secondary"
  687. onClick={() => setShowExportMenu(!showExportMenu)}
  688. disabled={isExporting}
  689. >
  690. {isExporting ? (
  691. <Loader2 className="w-4 h-4 animate-spin" />
  692. ) : (
  693. <FileSpreadsheet className="w-4 h-4" />
  694. )}
  695. {t('stats.exportStats')}
  696. </Button>
  697. {showExportMenu && (
  698. <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">
  699. <button
  700. 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"
  701. onClick={() => handleExport('csv')}
  702. >
  703. <FileText className="w-4 h-4" />
  704. {t('stats.exportAsCsv')}
  705. </button>
  706. <button
  707. 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"
  708. onClick={() => handleExport('xlsx')}
  709. >
  710. <FileSpreadsheet className="w-4 h-4" />
  711. {t('stats.exportAsExcel')}
  712. </button>
  713. </div>
  714. )}
  715. </div>
  716. </div>
  717. </div>
  718. <Dashboard
  719. key={dashboardKey}
  720. widgets={widgets}
  721. storageKey="bambusy-dashboard-layout"
  722. stackBelow={640}
  723. hideControls
  724. />
  725. </div>
  726. );
  727. }