StatsPage.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383
  1. import { useQuery } from '@tanstack/react-query';
  2. import {
  3. Package,
  4. Clock,
  5. CheckCircle,
  6. XCircle,
  7. DollarSign,
  8. Printer,
  9. Target,
  10. } from 'lucide-react';
  11. import { api } from '../api/client';
  12. import { PrintCalendar } from '../components/PrintCalendar';
  13. import { FilamentTrends } from '../components/FilamentTrends';
  14. import { Dashboard, type DashboardWidget } from '../components/Dashboard';
  15. // Widget Components
  16. function QuickStatsWidget({
  17. stats,
  18. currency,
  19. }: {
  20. stats: {
  21. total_prints: number;
  22. successful_prints: number;
  23. failed_prints: number;
  24. total_print_time_hours: number;
  25. total_filament_grams: number;
  26. total_cost: number;
  27. } | undefined;
  28. currency: string;
  29. }) {
  30. return (
  31. <div className="grid grid-cols-2 gap-4">
  32. <div className="flex items-start gap-3">
  33. <div className="p-2 rounded-lg bg-bambu-dark text-bambu-green">
  34. <Package className="w-5 h-5" />
  35. </div>
  36. <div>
  37. <p className="text-xs text-bambu-gray">Total Prints</p>
  38. <p className="text-xl font-bold text-white">{stats?.total_prints || 0}</p>
  39. </div>
  40. </div>
  41. <div className="flex items-start gap-3">
  42. <div className="p-2 rounded-lg bg-bambu-dark text-blue-400">
  43. <Clock className="w-5 h-5" />
  44. </div>
  45. <div>
  46. <p className="text-xs text-bambu-gray">Print Time</p>
  47. <p className="text-xl font-bold text-white">{stats?.total_print_time_hours.toFixed(1) || 0}h</p>
  48. </div>
  49. </div>
  50. <div className="flex items-start gap-3">
  51. <div className="p-2 rounded-lg bg-bambu-dark text-orange-400">
  52. <Package className="w-5 h-5" />
  53. </div>
  54. <div>
  55. <p className="text-xs text-bambu-gray">Filament Used</p>
  56. <p className="text-xl font-bold text-white">{((stats?.total_filament_grams || 0) / 1000).toFixed(2)}kg</p>
  57. </div>
  58. </div>
  59. <div className="flex items-start gap-3">
  60. <div className="p-2 rounded-lg bg-bambu-dark text-green-400">
  61. <DollarSign className="w-5 h-5" />
  62. </div>
  63. <div>
  64. <p className="text-xs text-bambu-gray">Total Cost</p>
  65. <p className="text-xl font-bold text-white">{currency} {stats?.total_cost.toFixed(2) || '0.00'}</p>
  66. </div>
  67. </div>
  68. </div>
  69. );
  70. }
  71. function SuccessRateWidget({
  72. stats,
  73. }: {
  74. stats: {
  75. total_prints: number;
  76. successful_prints: number;
  77. failed_prints: number;
  78. } | undefined;
  79. }) {
  80. const successRate = stats?.total_prints
  81. ? Math.round((stats.successful_prints / stats.total_prints) * 100)
  82. : 0;
  83. return (
  84. <div className="flex items-center gap-6">
  85. <div className="relative w-28 h-28">
  86. <svg className="w-full h-full -rotate-90">
  87. <circle cx="56" cy="56" r="48" fill="none" stroke="#3d3d3d" strokeWidth="10" />
  88. <circle
  89. cx="56"
  90. cy="56"
  91. r="48"
  92. fill="none"
  93. stroke="#00ae42"
  94. strokeWidth="10"
  95. strokeLinecap="round"
  96. strokeDasharray={`${successRate * 3.02} 302`}
  97. />
  98. </svg>
  99. <div className="absolute inset-0 flex items-center justify-center">
  100. <span className="text-xl font-bold text-white">{successRate}%</span>
  101. </div>
  102. </div>
  103. <div className="space-y-2">
  104. <div className="flex items-center gap-2">
  105. <CheckCircle className="w-4 h-4 text-bambu-green" />
  106. <span className="text-sm text-bambu-gray">Successful:</span>
  107. <span className="text-sm text-white font-medium">{stats?.successful_prints || 0}</span>
  108. </div>
  109. <div className="flex items-center gap-2">
  110. <XCircle className="w-4 h-4 text-red-400" />
  111. <span className="text-sm text-bambu-gray">Failed:</span>
  112. <span className="text-sm text-white font-medium">{stats?.failed_prints || 0}</span>
  113. </div>
  114. </div>
  115. </div>
  116. );
  117. }
  118. function TimeAccuracyWidget({
  119. stats,
  120. printerMap,
  121. }: {
  122. stats: {
  123. average_time_accuracy: number | null;
  124. time_accuracy_by_printer: Record<string, number> | null;
  125. } | undefined;
  126. printerMap: Map<string, string>;
  127. }) {
  128. const accuracy = stats?.average_time_accuracy;
  129. if (accuracy === null || accuracy === undefined) {
  130. return (
  131. <div className="flex items-center justify-center h-full">
  132. <p className="text-bambu-gray text-center py-4">No time accuracy data yet</p>
  133. </div>
  134. );
  135. }
  136. // Normalize accuracy for display (100% = perfect, clamp between 50-150 for gauge)
  137. const displayValue = Math.min(150, Math.max(50, accuracy));
  138. const normalizedForGauge = ((displayValue - 50) / 100) * 100; // 50-150 -> 0-100
  139. // Color based on accuracy
  140. const getColor = (acc: number) => {
  141. if (acc >= 95 && acc <= 105) return '#00ae42'; // Green - within 5%
  142. if (acc > 105) return '#3b82f6'; // Blue - faster than expected
  143. return '#f97316'; // Orange - slower than expected
  144. };
  145. const color = getColor(accuracy);
  146. const deviation = accuracy - 100;
  147. return (
  148. <div className="flex items-center gap-6">
  149. <div className="relative w-28 h-28">
  150. <svg className="w-full h-full -rotate-90">
  151. <circle cx="56" cy="56" r="48" fill="none" stroke="#3d3d3d" strokeWidth="10" />
  152. <circle
  153. cx="56"
  154. cy="56"
  155. r="48"
  156. fill="none"
  157. stroke={color}
  158. strokeWidth="10"
  159. strokeLinecap="round"
  160. strokeDasharray={`${normalizedForGauge * 3.02} 302`}
  161. />
  162. </svg>
  163. <div className="absolute inset-0 flex flex-col items-center justify-center">
  164. <span className="text-xl font-bold text-white">{accuracy.toFixed(0)}%</span>
  165. <span className={`text-xs ${deviation >= 0 ? 'text-blue-400' : 'text-orange-400'}`}>
  166. {deviation >= 0 ? '+' : ''}{deviation.toFixed(0)}%
  167. </span>
  168. </div>
  169. </div>
  170. <div className="space-y-2 flex-1">
  171. <div className="flex items-center gap-2 text-xs text-bambu-gray">
  172. <Target className="w-3 h-3" />
  173. <span>100% = perfect estimate</span>
  174. </div>
  175. {stats?.time_accuracy_by_printer && Object.keys(stats.time_accuracy_by_printer).length > 0 && (
  176. <div className="space-y-1 mt-2">
  177. {Object.entries(stats.time_accuracy_by_printer).slice(0, 3).map(([printerId, acc]) => (
  178. <div key={printerId} className="flex items-center justify-between text-xs">
  179. <span className="text-bambu-gray truncate max-w-[100px]">
  180. {printerMap.get(printerId) || `Printer ${printerId}`}
  181. </span>
  182. <span className={`font-medium ${
  183. acc >= 95 && acc <= 105 ? 'text-bambu-green' :
  184. acc > 105 ? 'text-blue-400' : 'text-orange-400'
  185. }`}>
  186. {acc.toFixed(0)}%
  187. </span>
  188. </div>
  189. ))}
  190. </div>
  191. )}
  192. </div>
  193. </div>
  194. );
  195. }
  196. function FilamentTypesWidget({
  197. stats,
  198. }: {
  199. stats: {
  200. total_prints: number;
  201. prints_by_filament_type: Record<string, number>;
  202. } | undefined;
  203. }) {
  204. if (!stats?.prints_by_filament_type || Object.keys(stats.prints_by_filament_type).length === 0) {
  205. return <p className="text-bambu-gray text-center py-4">No filament data available</p>;
  206. }
  207. // Sort by print count descending
  208. const sortedEntries = Object.entries(stats.prints_by_filament_type).sort(
  209. ([, a], [, b]) => b - a
  210. );
  211. return (
  212. <div className="space-y-3">
  213. {sortedEntries.map(([type, count]) => {
  214. const percentage = Math.round((count / (stats.total_prints || 1)) * 100);
  215. return (
  216. <div key={type}>
  217. <div className="flex justify-between text-sm mb-1">
  218. <span className="text-white">{type}</span>
  219. <span className="text-bambu-gray">{count} prints</span>
  220. </div>
  221. <div className="h-2 bg-bambu-dark rounded-full">
  222. <div
  223. className="h-full bg-bambu-green rounded-full transition-all"
  224. style={{ width: `${percentage}%` }}
  225. />
  226. </div>
  227. </div>
  228. );
  229. })}
  230. </div>
  231. );
  232. }
  233. function PrintActivityWidget({ printDates }: { printDates: string[] }) {
  234. return <PrintCalendar printDates={printDates} months={4} />;
  235. }
  236. function PrintsByPrinterWidget({
  237. stats,
  238. printerMap,
  239. }: {
  240. stats: { prints_by_printer: Record<string, number> } | undefined;
  241. printerMap: Map<string, string>;
  242. }) {
  243. if (!stats?.prints_by_printer || Object.keys(stats.prints_by_printer).length === 0) {
  244. return <p className="text-bambu-gray text-center py-4">No printer data available</p>;
  245. }
  246. return (
  247. <div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
  248. {Object.entries(stats.prints_by_printer).map(([printerId, count]) => (
  249. <div key={printerId} className="flex items-center gap-3 p-3 bg-bambu-dark rounded-lg">
  250. <div className="p-2 bg-bambu-dark-tertiary rounded-lg">
  251. <Printer className="w-4 h-4 text-bambu-green" />
  252. </div>
  253. <div>
  254. <p className="text-white font-medium text-sm">
  255. {printerMap.get(printerId) || `Printer ${printerId}`}
  256. </p>
  257. <p className="text-xs text-bambu-gray">{count} prints</p>
  258. </div>
  259. </div>
  260. ))}
  261. </div>
  262. );
  263. }
  264. function FilamentTrendsWidget({
  265. archives,
  266. currency,
  267. }: {
  268. archives: Parameters<typeof FilamentTrends>[0]['archives'];
  269. currency: string;
  270. }) {
  271. if (!archives || archives.length === 0) {
  272. return <p className="text-bambu-gray text-center py-4">No print data available</p>;
  273. }
  274. return <FilamentTrends archives={archives} currency={currency} />;
  275. }
  276. export function StatsPage() {
  277. const { data: stats, isLoading } = useQuery({
  278. queryKey: ['archiveStats'],
  279. queryFn: api.getArchiveStats,
  280. });
  281. const { data: printers } = useQuery({
  282. queryKey: ['printers'],
  283. queryFn: api.getPrinters,
  284. });
  285. const { data: archives } = useQuery({
  286. queryKey: ['archives'],
  287. queryFn: () => api.getArchives(undefined, 1000, 0),
  288. });
  289. const { data: settings } = useQuery({
  290. queryKey: ['settings'],
  291. queryFn: api.getSettings,
  292. });
  293. const currency = settings?.currency || '$';
  294. const printerMap = new Map(printers?.map((p) => [String(p.id), p.name]) || []);
  295. const printDates = archives?.map((a) => a.created_at) || [];
  296. if (isLoading) {
  297. return (
  298. <div className="p-8">
  299. <div className="text-center py-12 text-bambu-gray">Loading statistics...</div>
  300. </div>
  301. );
  302. }
  303. // Define dashboard widgets
  304. // Sizes: 1 = quarter (1/4), 2 = half (1/2), 4 = full width
  305. const widgets: DashboardWidget[] = [
  306. {
  307. id: 'quick-stats',
  308. title: 'Quick Stats',
  309. component: <QuickStatsWidget stats={stats} currency={currency} />,
  310. defaultSize: 2,
  311. },
  312. {
  313. id: 'success-rate',
  314. title: 'Success Rate',
  315. component: <SuccessRateWidget stats={stats} />,
  316. defaultSize: 1,
  317. },
  318. {
  319. id: 'time-accuracy',
  320. title: 'Time Accuracy',
  321. component: <TimeAccuracyWidget stats={stats} printerMap={printerMap} />,
  322. defaultSize: 1,
  323. },
  324. {
  325. id: 'filament-types',
  326. title: 'Filament Types',
  327. component: <FilamentTypesWidget stats={stats} />,
  328. defaultSize: 1,
  329. },
  330. {
  331. id: 'print-activity',
  332. title: 'Print Activity',
  333. component: <PrintActivityWidget printDates={printDates} />,
  334. defaultSize: 2,
  335. },
  336. {
  337. id: 'prints-by-printer',
  338. title: 'Prints by Printer',
  339. component: <PrintsByPrinterWidget stats={stats} printerMap={printerMap} />,
  340. defaultSize: 2,
  341. },
  342. {
  343. id: 'filament-trends',
  344. title: 'Filament Usage Trends',
  345. component: <FilamentTrendsWidget archives={archives || []} currency={currency} />,
  346. defaultSize: 4,
  347. },
  348. ];
  349. return (
  350. <div className="p-8">
  351. <div className="mb-6">
  352. <h1 className="text-2xl font-bold text-white">Dashboard</h1>
  353. <p className="text-bambu-gray">Drag widgets to rearrange. Click the eye icon to hide.</p>
  354. </div>
  355. <Dashboard widgets={widgets} storageKey="bambusy-dashboard-layout" />
  356. </div>
  357. );
  358. }