StatsPage.tsx 44 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194
  1. import { useQuery } from '@tanstack/react-query';
  2. import { useState, useEffect, useMemo } from 'react';
  3. import { useTranslation } from 'react-i18next';
  4. import {
  5. Package,
  6. Clock,
  7. CheckCircle,
  8. XCircle,
  9. DollarSign,
  10. Target,
  11. Zap,
  12. AlertTriangle,
  13. TrendingDown,
  14. FileSpreadsheet,
  15. FileText,
  16. Loader2,
  17. Eye,
  18. RotateCcw,
  19. Calculator,
  20. Calendar,
  21. ChevronDown,
  22. } from 'lucide-react';
  23. import {
  24. BarChart,
  25. Bar,
  26. XAxis,
  27. YAxis,
  28. CartesianGrid,
  29. Tooltip,
  30. ResponsiveContainer,
  31. } from 'recharts';
  32. import { Button } from '../components/Button';
  33. import { useToast } from '../contexts/ToastContext';
  34. import { useAuth } from '../contexts/AuthContext';
  35. import { api, type Archive } from '../api/client';
  36. import { PrintCalendar } from '../components/PrintCalendar';
  37. import { FilamentTrends } from '../components/FilamentTrends';
  38. import { Dashboard, type DashboardWidget } from '../components/Dashboard';
  39. import { getCurrencySymbol } from '../utils/currency';
  40. import { formatWeight } from '../utils/weight';
  41. import { parseUTCDate, formatDuration } from '../utils/date';
  42. import { MetricToggle, type Metric } from '../components/MetricToggle';
  43. // Timeframe types and helpers
  44. type TimeframePreset = 'today' | 'this-week' | 'this-month' | 'last-7' | 'last-30' | 'last-90' | 'this-year' | 'all-time' | 'custom';
  45. interface TimeframeState {
  46. preset: TimeframePreset;
  47. dateFrom: string | undefined; // YYYY-MM-DD
  48. dateTo: string | undefined; // YYYY-MM-DD
  49. }
  50. function computeDateRange(preset: TimeframePreset): { dateFrom?: string; dateTo?: string } {
  51. const now = new Date();
  52. const y = now.getUTCFullYear(), m = now.getUTCMonth(), d = now.getUTCDate();
  53. const fmt = (dt: Date) => dt.toISOString().split('T')[0];
  54. const todayStr = fmt(now);
  55. switch (preset) {
  56. case 'today':
  57. return { dateFrom: todayStr, dateTo: todayStr };
  58. case 'this-week': {
  59. const day = now.getUTCDay();
  60. const start = new Date(Date.UTC(y, m, d - (day === 0 ? 6 : day - 1)));
  61. return { dateFrom: fmt(start), dateTo: todayStr };
  62. }
  63. case 'this-month':
  64. return { dateFrom: fmt(new Date(Date.UTC(y, m, 1))), dateTo: todayStr };
  65. case 'last-7':
  66. return { dateFrom: fmt(new Date(Date.UTC(y, m, d - 6))), dateTo: todayStr };
  67. case 'last-30':
  68. return { dateFrom: fmt(new Date(Date.UTC(y, m, d - 29))), dateTo: todayStr };
  69. case 'last-90':
  70. return { dateFrom: fmt(new Date(Date.UTC(y, m, d - 89))), dateTo: todayStr };
  71. case 'this-year':
  72. return { dateFrom: fmt(new Date(Date.UTC(y, 0, 1))), dateTo: todayStr };
  73. case 'all-time':
  74. return { dateFrom: undefined, dateTo: undefined };
  75. case 'custom':
  76. return {};
  77. }
  78. }
  79. const TIMEFRAME_PRESETS: TimeframePreset[] = [
  80. 'today', 'this-week', 'this-month',
  81. 'last-7', 'last-30', 'last-90',
  82. 'this-year', 'all-time',
  83. ];
  84. // Constants
  85. const DAY_LABELS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
  86. const HOUR_LABELS = [
  87. '12am', '1am', '2am', '3am', '4am', '5am',
  88. '6am', '7am', '8am', '9am', '10am', '11am',
  89. '12pm', '1pm', '2pm', '3pm', '4pm', '5pm',
  90. '6pm', '7pm', '8pm', '9pm', '10pm', '11pm',
  91. ];
  92. const DURATION_BUCKETS = [
  93. { key: '<30m', max: 1800 },
  94. { key: '30m-1h', max: 3600 },
  95. { key: '1-2h', max: 7200 },
  96. { key: '2-4h', max: 14400 },
  97. { key: '4-8h', max: 28800 },
  98. { key: '8-12h', max: 43200 },
  99. { key: '12-24h', max: 86400 },
  100. { key: '24h+', max: Infinity },
  101. ];
  102. const RECHARTS_TOOLTIP_STYLE = {
  103. backgroundColor: '#2d2d2d',
  104. border: '1px solid #3d3d3d',
  105. borderRadius: '8px',
  106. };
  107. // Widget Components
  108. function QuickStatsWidget({
  109. stats,
  110. currency,
  111. }: {
  112. stats: {
  113. total_prints: number;
  114. successful_prints: number;
  115. failed_prints: number;
  116. total_print_time_hours: number;
  117. total_filament_grams: number;
  118. total_cost: number;
  119. total_energy_kwh: number;
  120. total_energy_cost: number;
  121. } | undefined;
  122. currency: string;
  123. }) {
  124. const { t } = useTranslation();
  125. return (
  126. <div className="grid grid-cols-2 sm:grid-cols-3 gap-4">
  127. <div className="flex items-start gap-3">
  128. <div className="p-2 rounded-lg bg-bambu-dark text-bambu-green">
  129. <Package className="w-5 h-5" />
  130. </div>
  131. <div>
  132. <p className="text-xs text-bambu-gray">{t('stats.totalPrints')}</p>
  133. <p className="text-xl font-bold text-white">{stats?.total_prints || 0}</p>
  134. </div>
  135. </div>
  136. <div className="flex items-start gap-3">
  137. <div className="p-2 rounded-lg bg-bambu-dark text-blue-400">
  138. <Clock className="w-5 h-5" />
  139. </div>
  140. <div>
  141. <p className="text-xs text-bambu-gray">{t('stats.printTime')}</p>
  142. <p className="text-xl font-bold text-white">{stats?.total_print_time_hours.toFixed(1) || 0}h</p>
  143. </div>
  144. </div>
  145. <div className="flex items-start gap-3">
  146. <div className="p-2 rounded-lg bg-bambu-dark text-orange-400">
  147. <Package className="w-5 h-5" />
  148. </div>
  149. <div>
  150. <p className="text-xs text-bambu-gray">{t('stats.filamentUsed')}</p>
  151. <p className="text-xl font-bold text-white">{formatWeight(stats?.total_filament_grams || 0)}</p>
  152. </div>
  153. </div>
  154. <div className="flex items-start gap-3">
  155. <div className="p-2 rounded-lg bg-bambu-dark text-green-400">
  156. <DollarSign className="w-5 h-5" />
  157. </div>
  158. <div>
  159. <p className="text-xs text-bambu-gray">{t('stats.filamentCost')}</p>
  160. <p className="text-xl font-bold text-white">{currency} {stats?.total_cost.toFixed(2) || '0.00'}</p>
  161. </div>
  162. </div>
  163. <div className="flex items-start gap-3">
  164. <div className="p-2 rounded-lg bg-bambu-dark text-yellow-400">
  165. <Zap className="w-5 h-5" />
  166. </div>
  167. <div>
  168. <p className="text-xs text-bambu-gray">{t('stats.energyUsed')}</p>
  169. <p className="text-xl font-bold text-white">{stats?.total_energy_kwh.toFixed(3) || '0.000'} kWh</p>
  170. </div>
  171. </div>
  172. <div className="flex items-start gap-3">
  173. <div className="p-2 rounded-lg bg-bambu-dark text-yellow-500">
  174. <DollarSign className="w-5 h-5" />
  175. </div>
  176. <div>
  177. <p className="text-xs text-bambu-gray">{t('stats.energyCost')}</p>
  178. <p className="text-xl font-bold text-white">{currency} {stats?.total_energy_cost.toFixed(2) || '0.00'}</p>
  179. </div>
  180. </div>
  181. </div>
  182. );
  183. }
  184. function SuccessRateWidget({
  185. stats,
  186. printerMap,
  187. size = 1,
  188. }: {
  189. stats: {
  190. total_prints: number;
  191. successful_prints: number;
  192. failed_prints: number;
  193. prints_by_printer: Record<string, number>;
  194. } | undefined;
  195. printerMap: Map<string, string>;
  196. size?: 1 | 2 | 4;
  197. }) {
  198. const { t } = useTranslation();
  199. const completedAndFailed = (stats?.successful_prints || 0) + (stats?.failed_prints || 0);
  200. const successRate = completedAndFailed
  201. ? Math.round((stats!.successful_prints / completedAndFailed) * 100)
  202. : 0;
  203. // Scale gauge size based on widget size
  204. const gaugeSize = size === 1 ? 112 : size === 2 ? 128 : 144;
  205. const radius = gaugeSize / 2 - 8;
  206. const circumference = radius * 2 * Math.PI;
  207. return (
  208. <div className="flex items-center gap-6">
  209. <div className="relative flex-shrink-0" style={{ width: gaugeSize, height: gaugeSize }}>
  210. <svg className="w-full h-full -rotate-90">
  211. <circle
  212. cx={gaugeSize / 2}
  213. cy={gaugeSize / 2}
  214. r={radius}
  215. fill="none"
  216. stroke="#3d3d3d"
  217. strokeWidth="10"
  218. />
  219. <circle
  220. cx={gaugeSize / 2}
  221. cy={gaugeSize / 2}
  222. r={radius}
  223. fill="none"
  224. stroke="#00ae42"
  225. strokeWidth="10"
  226. strokeLinecap="round"
  227. strokeDasharray={`${(successRate / 100) * circumference} ${circumference}`}
  228. />
  229. </svg>
  230. <div className="absolute inset-0 flex items-center justify-center">
  231. <span className={`font-bold text-white ${size >= 2 ? 'text-2xl' : 'text-xl'}`}>{successRate}%</span>
  232. </div>
  233. </div>
  234. <div className="flex-1 min-w-0">
  235. <div className="space-y-2">
  236. <div className="flex items-center gap-2">
  237. <CheckCircle className="w-4 h-4 text-status-ok flex-shrink-0" />
  238. <span className="text-sm text-bambu-gray">{t('stats.successful')}</span>
  239. <span className="text-sm text-white font-medium">{stats?.successful_prints || 0}</span>
  240. </div>
  241. <div className="flex items-center gap-2">
  242. <XCircle className="w-4 h-4 text-status-error flex-shrink-0" />
  243. <span className="text-sm text-bambu-gray">{t('stats.failed')}</span>
  244. <span className="text-sm text-white font-medium">{stats?.failed_prints || 0}</span>
  245. </div>
  246. </div>
  247. {/* Show per-printer breakdown when expanded */}
  248. {size >= 2 && stats?.prints_by_printer && Object.keys(stats.prints_by_printer).length > 0 && (
  249. <div className="mt-4 pt-4 border-t border-bambu-dark-tertiary">
  250. <p className="text-xs text-bambu-gray font-medium mb-2">{t('stats.printsByPrinter')}</p>
  251. <div className={`grid gap-x-6 gap-y-1 ${size === 4 ? 'grid-cols-3' : 'grid-cols-2'}`} style={{ width: 'fit-content' }}>
  252. {Object.entries(stats.prints_by_printer).map(([printerId, count]) => (
  253. <div key={printerId} className="flex items-center gap-3 text-sm">
  254. <span className="text-bambu-gray truncate max-w-[120px]">
  255. {printerMap.get(printerId) || `${t('common.printer')} ${printerId}`}
  256. </span>
  257. <span className="text-white font-medium">{count}</span>
  258. </div>
  259. ))}
  260. </div>
  261. </div>
  262. )}
  263. </div>
  264. </div>
  265. );
  266. }
  267. function TimeAccuracyWidget({
  268. stats,
  269. printerMap,
  270. size = 1,
  271. }: {
  272. stats: {
  273. average_time_accuracy: number | null;
  274. time_accuracy_by_printer: Record<string, number> | null;
  275. } | undefined;
  276. printerMap: Map<string, string>;
  277. size?: 1 | 2 | 4;
  278. }) {
  279. const { t } = useTranslation();
  280. const accuracy = stats?.average_time_accuracy;
  281. if (accuracy === null || accuracy === undefined) {
  282. return (
  283. <div className="flex items-center justify-center h-full">
  284. <p className="text-bambu-gray text-center py-4">{t('stats.noTimeAccuracyData')}</p>
  285. </div>
  286. );
  287. }
  288. // Normalize accuracy for display (100% = perfect, clamp between 50-150 for gauge)
  289. const displayValue = Math.min(150, Math.max(50, accuracy));
  290. const normalizedForGauge = ((displayValue - 50) / 100) * 100; // 50-150 -> 0-100
  291. // Color based on accuracy
  292. const getColor = (acc: number) => {
  293. if (acc >= 95 && acc <= 105) return '#00ae42'; // Green - within 5%
  294. if (acc > 105) return '#3b82f6'; // Blue - faster than expected
  295. return '#f97316'; // Orange - slower than expected
  296. };
  297. const color = getColor(accuracy);
  298. const deviation = accuracy - 100;
  299. // Scale gauge size based on widget size
  300. const gaugeSize = size === 1 ? 112 : size === 2 ? 128 : 144;
  301. const radius = gaugeSize / 2 - 8;
  302. const circumference = radius * 2 * Math.PI;
  303. // Show more printers when expanded
  304. const maxPrinters = size === 1 ? 3 : size === 2 ? 6 : 999;
  305. const printerEntries = stats?.time_accuracy_by_printer
  306. ? Object.entries(stats.time_accuracy_by_printer).slice(0, maxPrinters)
  307. : [];
  308. return (
  309. <div className="flex items-center gap-6">
  310. <div className="relative flex-shrink-0" style={{ width: gaugeSize, height: gaugeSize }}>
  311. <svg className="w-full h-full -rotate-90">
  312. <circle
  313. cx={gaugeSize / 2}
  314. cy={gaugeSize / 2}
  315. r={radius}
  316. fill="none"
  317. stroke="#3d3d3d"
  318. strokeWidth="10"
  319. />
  320. <circle
  321. cx={gaugeSize / 2}
  322. cy={gaugeSize / 2}
  323. r={radius}
  324. fill="none"
  325. stroke={color}
  326. strokeWidth="10"
  327. strokeLinecap="round"
  328. strokeDasharray={`${(normalizedForGauge / 100) * circumference} ${circumference}`}
  329. />
  330. </svg>
  331. <div className="absolute inset-0 flex flex-col items-center justify-center">
  332. <span className={`font-bold text-white ${size >= 2 ? 'text-2xl' : 'text-xl'}`}>{accuracy.toFixed(0)}%</span>
  333. <span className={`text-xs ${deviation >= 0 ? 'text-blue-400' : 'text-orange-400'}`}>
  334. {deviation >= 0 ? '+' : ''}{deviation.toFixed(0)}%
  335. </span>
  336. </div>
  337. </div>
  338. <div className="flex-1 min-w-0">
  339. <div className="flex items-center gap-2 text-xs text-bambu-gray">
  340. <Target className="w-3 h-3 flex-shrink-0" />
  341. <span>{t('stats.perfectEstimate')}</span>
  342. </div>
  343. {printerEntries.length > 0 && (
  344. <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' }}>
  345. {printerEntries.map(([printerId, acc]) => (
  346. <div key={printerId} className="flex items-center gap-2 text-xs">
  347. <span className="text-bambu-gray truncate max-w-[100px]">
  348. {printerMap.get(printerId) || `${t('common.printer')} ${printerId}`}
  349. </span>
  350. <span className={`font-medium ${
  351. acc >= 95 && acc <= 105 ? 'text-status-ok' :
  352. acc > 105 ? 'text-blue-400' : 'text-status-warning'
  353. }`}>
  354. {acc.toFixed(0)}%
  355. </span>
  356. </div>
  357. ))}
  358. </div>
  359. )}
  360. </div>
  361. </div>
  362. );
  363. }
  364. function PrintActivityWidget({
  365. printDates,
  366. size = 2,
  367. }: {
  368. printDates: string[];
  369. size?: 1 | 2 | 4;
  370. }) {
  371. // Show more months when widget is larger - cell size auto-calculated
  372. const months = size === 1 ? 3 : size === 2 ? 6 : 12;
  373. return <PrintCalendar printDates={printDates} months={months} />;
  374. }
  375. function PrinterStatsWidget({
  376. stats,
  377. archives,
  378. printerMap,
  379. }: {
  380. stats: { prints_by_printer: Record<string, number> } | undefined;
  381. archives: Archive[];
  382. printerMap: Map<string, string>;
  383. }) {
  384. const { t } = useTranslation();
  385. const [printerMetric, setPrinterMetric] = useState<Metric>('weight');
  386. const [habitsMetric, setHabitsMetric] = useState<Metric>('weight');
  387. // Per-printer data
  388. const printerData = useMemo(() => {
  389. const map = new Map<string, { prints: number; weight: number; time: number }>();
  390. if (stats?.prints_by_printer) {
  391. Object.entries(stats.prints_by_printer).forEach(([id, count]) => {
  392. const entry = map.get(id) || { prints: 0, weight: 0, time: 0 };
  393. entry.prints = count;
  394. map.set(id, entry);
  395. });
  396. }
  397. archives.forEach(a => {
  398. if (!a.printer_id) return;
  399. const id = String(a.printer_id);
  400. const entry = map.get(id) || { prints: 0, weight: 0, time: 0 };
  401. entry.weight += a.filament_used_grams || 0;
  402. entry.time += a.actual_time_seconds || a.print_time_seconds || 0;
  403. if (!stats?.prints_by_printer) entry.prints++;
  404. map.set(id, entry);
  405. });
  406. return Array.from(map.entries())
  407. .map(([id, v]) => ({
  408. name: printerMap.get(id) || `${t('common.printer')} ${id}`,
  409. value: printerMetric === 'prints' ? v.prints :
  410. printerMetric === 'weight' ? Math.round(v.weight) :
  411. Math.round((v.time / 3600) * 10) / 10,
  412. }))
  413. .sort((a, b) => b.value - a.value);
  414. }, [stats, archives, printerMap, printerMetric, t]);
  415. // Hourly distribution (time of day)
  416. const hourlyData = useMemo(() => {
  417. const hours = Array.from({ length: 24 }, (_, i) => ({
  418. hour: i,
  419. label: HOUR_LABELS[i],
  420. total: 0,
  421. failures: 0,
  422. }));
  423. archives.forEach(a => {
  424. if (!a.started_at) return;
  425. const date = parseUTCDate(a.started_at);
  426. if (!date) return;
  427. const h = date.getHours();
  428. hours[h].total++;
  429. if (a.status === 'failed') {
  430. hours[h].failures++;
  431. }
  432. });
  433. return hours;
  434. }, [archives]);
  435. // Duration distribution
  436. const durationData = useMemo(() => {
  437. const counts = DURATION_BUCKETS.map(b => ({ name: b.key, count: 0 }));
  438. archives.forEach(a => {
  439. const seconds = a.actual_time_seconds || a.print_time_seconds;
  440. if (!seconds || seconds <= 0) return;
  441. for (let i = 0; i < DURATION_BUCKETS.length; i++) {
  442. if (seconds <= DURATION_BUCKETS[i].max) {
  443. counts[i].count++;
  444. break;
  445. }
  446. }
  447. });
  448. return counts;
  449. }, [archives]);
  450. // Habits (avg per day-of-week)
  451. const habitsData = useMemo(() => {
  452. const dayValues = [0, 0, 0, 0, 0, 0, 0];
  453. const weeksSet = new Set<string>();
  454. archives.forEach(a => {
  455. const date = parseUTCDate(a.created_at) || new Date(a.created_at);
  456. let day = date.getDay() - 1;
  457. if (day < 0) day = 6;
  458. if (habitsMetric === 'prints') dayValues[day]++;
  459. else if (habitsMetric === 'weight') dayValues[day] += a.filament_used_grams || 0;
  460. else dayValues[day] += (a.actual_time_seconds || a.print_time_seconds || 0) / 3600;
  461. const weekStart = new Date(date);
  462. weekStart.setDate(date.getDate() - ((date.getDay() + 6) % 7));
  463. weeksSet.add(weekStart.toISOString().split('T')[0]);
  464. });
  465. const numWeeks = Math.max(weeksSet.size, 1);
  466. return DAY_LABELS.map((name, i) => ({
  467. name,
  468. avg: Math.round((dayValues[i] / numWeeks) * 10) / 10,
  469. }));
  470. }, [archives, habitsMetric]);
  471. const pUnit = printerMetric === 'weight' ? 'g' : printerMetric === 'time' ? 'h' : '';
  472. const pLabel = printerMetric === 'weight' ? t('stats.filamentByWeight') : printerMetric === 'time' ? t('stats.hours') : t('common.prints');
  473. const pColor = printerMetric === 'weight' ? '#00ae42' : printerMetric === 'time' ? '#3b82f6' : '#f59e0b';
  474. const hUnit = habitsMetric === 'weight' ? 'g' : habitsMetric === 'time' ? 'h' : '';
  475. const hLabel = habitsMetric === 'weight' ? t('stats.avgWeight') : habitsMetric === 'time' ? t('stats.avgTime') : t('stats.avgPrints');
  476. const hColor = habitsMetric === 'weight' ? '#00ae42' : habitsMetric === 'time' ? '#3b82f6' : '#f59e0b';
  477. return (
  478. <div className="space-y-4">
  479. {/* By Printer */}
  480. <div className="bg-bambu-dark rounded-lg p-4">
  481. <div className="flex items-center justify-between mb-3">
  482. <h4 className="text-sm font-medium text-bambu-gray">{t('stats.printsByPrinter')}</h4>
  483. <MetricToggle value={printerMetric} onChange={setPrinterMetric} />
  484. </div>
  485. {printerData.length > 0 ? (
  486. <ResponsiveContainer width="100%" height={Math.max(140, printerData.length * 40)}>
  487. <BarChart data={printerData} layout="vertical" margin={{ left: 10 }}>
  488. <CartesianGrid strokeDasharray="3 3" stroke="#3d3d3d" />
  489. <XAxis type="number" stroke="#9ca3af" tick={{ fontSize: 11 }} unit={pUnit} />
  490. <YAxis type="category" dataKey="name" stroke="#9ca3af" tick={{ fontSize: 11 }} width={100} />
  491. <Tooltip
  492. contentStyle={RECHARTS_TOOLTIP_STYLE}
  493. formatter={(v: number | undefined) => [
  494. printerMetric === 'weight' ? formatWeight(Number(v ?? 0)) : `${v ?? 0}${pUnit}`,
  495. pLabel,
  496. ]}
  497. />
  498. <Bar dataKey="value" fill={pColor} radius={[0, 4, 4, 0]} />
  499. </BarChart>
  500. </ResponsiveContainer>
  501. ) : (
  502. <p className="text-bambu-gray text-center py-4">{t('stats.noPrinterData')}</p>
  503. )}
  504. </div>
  505. <div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
  506. {/* Print Duration */}
  507. <div className="bg-bambu-dark rounded-lg p-4">
  508. <h4 className="text-sm font-medium text-bambu-gray mb-3">{t('stats.printDuration')}</h4>
  509. {archives.length > 0 ? (
  510. <ResponsiveContainer width="100%" height={160}>
  511. <BarChart data={durationData}>
  512. <CartesianGrid strokeDasharray="3 3" stroke="#3d3d3d" />
  513. <XAxis dataKey="name" stroke="#9ca3af" tick={{ fontSize: 11 }} />
  514. <YAxis stroke="#9ca3af" tick={{ fontSize: 11 }} allowDecimals={false} />
  515. <Tooltip contentStyle={RECHARTS_TOOLTIP_STYLE} />
  516. <Bar dataKey="count" name={t('common.prints')} fill="#00ae42" radius={[4, 4, 0, 0]} />
  517. </BarChart>
  518. </ResponsiveContainer>
  519. ) : (
  520. <p className="text-bambu-gray text-center py-4">{t('stats.noArchiveData')}</p>
  521. )}
  522. </div>
  523. {/* Print Habits */}
  524. <div className="bg-bambu-dark rounded-lg p-4">
  525. <div className="flex items-center justify-between mb-3">
  526. <h4 className="text-sm font-medium text-bambu-gray">{t('stats.printHabits')}</h4>
  527. <MetricToggle value={habitsMetric} onChange={setHabitsMetric} />
  528. </div>
  529. {archives.length > 0 ? (
  530. <ResponsiveContainer width="100%" height={160}>
  531. <BarChart data={habitsData}>
  532. <CartesianGrid strokeDasharray="3 3" stroke="#3d3d3d" />
  533. <XAxis dataKey="name" stroke="#9ca3af" tick={{ fontSize: 11 }} />
  534. <YAxis stroke="#9ca3af" tick={{ fontSize: 11 }} unit={hUnit} />
  535. <Tooltip contentStyle={RECHARTS_TOOLTIP_STYLE} formatter={(v: number | undefined) => [`${v ?? 0}${hUnit}`, hLabel]} />
  536. <Bar dataKey="avg" fill={hColor} radius={[4, 4, 0, 0]} />
  537. </BarChart>
  538. </ResponsiveContainer>
  539. ) : (
  540. <p className="text-bambu-gray text-center py-4">{t('stats.noArchiveData')}</p>
  541. )}
  542. </div>
  543. {/* Print Time of Day */}
  544. <div className="bg-bambu-dark rounded-lg p-4">
  545. <h4 className="text-sm font-medium text-bambu-gray mb-3">{t('stats.printTimeOfDay')}</h4>
  546. {archives.length > 0 ? (
  547. <ResponsiveContainer width="100%" height={160}>
  548. <BarChart data={hourlyData}>
  549. <CartesianGrid strokeDasharray="3 3" stroke="#3d3d3d" />
  550. <XAxis dataKey="label" stroke="#9ca3af" tick={{ fontSize: 10 }} interval={5} />
  551. <YAxis stroke="#9ca3af" tick={{ fontSize: 11 }} allowDecimals={false} />
  552. <Tooltip contentStyle={RECHARTS_TOOLTIP_STYLE} />
  553. <Bar dataKey="total" name={t('stats.totalPrints')} fill="#00ae42" radius={[2, 2, 0, 0]} />
  554. <Bar dataKey="failures" name={t('stats.failed')} fill="#ef4444" radius={[2, 2, 0, 0]} />
  555. </BarChart>
  556. </ResponsiveContainer>
  557. ) : (
  558. <p className="text-bambu-gray text-center py-4">{t('stats.noArchiveData')}</p>
  559. )}
  560. </div>
  561. </div>
  562. </div>
  563. );
  564. }
  565. function FilamentTrendsWidget({
  566. archives,
  567. currency,
  568. }: {
  569. archives: Parameters<typeof FilamentTrends>[0]['archives'];
  570. currency: string;
  571. }) {
  572. const { t } = useTranslation();
  573. if (!archives || archives.length === 0) {
  574. return <p className="text-bambu-gray text-center py-4">{t('stats.noPrintData')}</p>;
  575. }
  576. return <FilamentTrends archives={archives} currency={currency} />;
  577. }
  578. function FailureAnalysisWidget({ size = 1, dateFrom, dateTo }: {
  579. size?: 1 | 2 | 4;
  580. dateFrom?: string;
  581. dateTo?: string;
  582. }) {
  583. const { t } = useTranslation();
  584. const hasDateRange = !!(dateFrom || dateTo);
  585. const { data: analysis, isLoading } = useQuery({
  586. queryKey: ['failureAnalysis', dateFrom, dateTo],
  587. queryFn: () => api.getFailureAnalysis({
  588. ...(hasDateRange ? { dateFrom, dateTo } : { days: 30 }),
  589. }),
  590. });
  591. if (isLoading) {
  592. return (
  593. <div className="flex justify-center py-4">
  594. <Loader2 className="w-6 h-6 text-bambu-green animate-spin" />
  595. </div>
  596. );
  597. }
  598. if (!analysis || analysis.total_prints === 0) {
  599. return <p className="text-bambu-gray text-center py-4">{hasDateRange ? t('stats.noPrintDataInRange') : t('stats.noPrintDataLast30Days')}</p>;
  600. }
  601. // Show more reasons when expanded
  602. const maxReasons = size === 1 ? 5 : size === 2 ? 8 : 999;
  603. const allReasons = Object.entries(analysis.failures_by_reason).sort(([, a], [, b]) => b - a);
  604. const topReasons = allReasons.slice(0, maxReasons);
  605. const hasMore = allReasons.length > maxReasons;
  606. return (
  607. <div className={`${size >= 2 ? 'flex gap-8' : 'space-y-4'}`}>
  608. {/* Summary */}
  609. <div className={size >= 2 ? 'flex-shrink-0' : ''}>
  610. <div className="flex items-center gap-4">
  611. <div className="flex items-center gap-2">
  612. <AlertTriangle className={`w-5 h-5 ${analysis.failure_rate > 20 ? 'text-status-error' : analysis.failure_rate > 10 ? 'text-status-warning' : 'text-status-ok'}`} />
  613. <span className={`font-bold text-white ${size >= 2 ? 'text-3xl' : 'text-2xl'}`}>{analysis.failure_rate.toFixed(1)}%</span>
  614. </div>
  615. </div>
  616. <div className="text-sm text-bambu-gray mt-1">
  617. {t('stats.failedPrintsCount', { failed: analysis.failed_prints, total: analysis.total_prints })}
  618. </div>
  619. {/* Trend indicator */}
  620. {analysis.trend && analysis.trend.length >= 2 && (
  621. <div className={`${size >= 2 ? 'mt-4' : 'mt-2 pt-2 border-t border-bambu-dark-tertiary'}`}>
  622. <div className="flex items-center gap-2 text-sm">
  623. <TrendingDown className={`w-4 h-4 ${
  624. analysis.trend[analysis.trend.length - 1].failure_rate < analysis.trend[analysis.trend.length - 2].failure_rate
  625. ? 'text-status-ok'
  626. : 'text-status-error'
  627. }`} />
  628. <span className="text-bambu-gray">
  629. {t('stats.lastWeekRate', { rate: analysis.trend[analysis.trend.length - 1].failure_rate.toFixed(1) })}
  630. </span>
  631. </div>
  632. </div>
  633. )}
  634. </div>
  635. {/* Failure Reasons */}
  636. {topReasons.length > 0 && (
  637. <div className={`flex-1 ${size >= 2 ? 'border-l border-bambu-dark-tertiary pl-8' : 'pt-2'}`}>
  638. <p className="text-xs text-bambu-gray font-medium mb-2">
  639. {size >= 2 ? t('stats.failureReasons') : t('stats.topFailureReasons')}
  640. </p>
  641. <div className={`${size === 4 ? 'grid grid-cols-2 gap-x-6 gap-y-1' : 'space-y-1'}`}>
  642. {topReasons.map(([reason, count]) => (
  643. <div key={reason} className="flex items-center justify-between text-sm">
  644. <span className={`text-white truncate ${size === 4 ? 'max-w-[200px]' : 'max-w-[160px]'}`}>
  645. {reason || t('common.unknown')}
  646. </span>
  647. <span className="text-bambu-gray ml-2">{count}</span>
  648. </div>
  649. ))}
  650. </div>
  651. {hasMore && (
  652. <p className="text-xs text-bambu-gray mt-2">
  653. {t('common.more', { count: allReasons.length - maxReasons })}
  654. </p>
  655. )}
  656. </div>
  657. )}
  658. </div>
  659. );
  660. }
  661. function RecordsWidget({ archives, currency }: { archives: Archive[]; currency: string }) {
  662. const { t } = useTranslation();
  663. const records = useMemo(() => {
  664. const result: Array<{
  665. icon: typeof Clock;
  666. iconColor: string;
  667. label: string;
  668. value: string;
  669. detail: string | null;
  670. }> = [];
  671. if (archives.length === 0) return result;
  672. // Longest print
  673. let longestArchive: Archive | null = null;
  674. let longestTime = 0;
  675. archives.forEach(a => {
  676. if (a.actual_time_seconds && a.actual_time_seconds > longestTime) {
  677. longestTime = a.actual_time_seconds;
  678. longestArchive = a;
  679. }
  680. });
  681. if (longestArchive && longestTime > 0) {
  682. result.push({
  683. icon: Clock,
  684. iconColor: 'text-blue-400',
  685. label: t('stats.longestPrint'),
  686. value: formatDuration(longestTime),
  687. detail: (longestArchive as Archive).print_name || null,
  688. });
  689. }
  690. // Heaviest print
  691. let heaviestArchive: Archive | null = null;
  692. let heaviestWeight = 0;
  693. archives.forEach(a => {
  694. if (a.filament_used_grams && a.filament_used_grams > heaviestWeight) {
  695. heaviestWeight = a.filament_used_grams;
  696. heaviestArchive = a;
  697. }
  698. });
  699. if (heaviestArchive && heaviestWeight > 0) {
  700. result.push({
  701. icon: Package,
  702. iconColor: 'text-orange-400',
  703. label: t('stats.heaviestPrint'),
  704. value: formatWeight(heaviestWeight),
  705. detail: (heaviestArchive as Archive).print_name || null,
  706. });
  707. }
  708. // Most expensive print
  709. let costliestArchive: Archive | null = null;
  710. let highestCost = 0;
  711. archives.forEach(a => {
  712. if (a.cost && a.cost > highestCost) {
  713. highestCost = a.cost;
  714. costliestArchive = a;
  715. }
  716. });
  717. if (costliestArchive && highestCost > 0) {
  718. result.push({
  719. icon: DollarSign,
  720. iconColor: 'text-green-400',
  721. label: t('stats.mostExpensivePrint'),
  722. value: `${currency}${highestCost.toFixed(2)}`,
  723. detail: (costliestArchive as Archive).print_name || null,
  724. });
  725. }
  726. // Busiest day
  727. const dayCounts = new Map<string, number>();
  728. archives.forEach(a => {
  729. const date = parseUTCDate(a.created_at) || new Date(a.created_at);
  730. const key = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
  731. dayCounts.set(key, (dayCounts.get(key) || 0) + 1);
  732. });
  733. let busiestDay = '';
  734. let busiestCount = 0;
  735. dayCounts.forEach((count, day) => {
  736. if (count > busiestCount) {
  737. busiestCount = count;
  738. busiestDay = day;
  739. }
  740. });
  741. if (busiestCount > 1) {
  742. result.push({
  743. icon: Calendar,
  744. iconColor: 'text-purple-400',
  745. label: t('stats.busiestDay'),
  746. value: `${busiestCount} ${t('common.prints')}`,
  747. detail: new Date(busiestDay).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }),
  748. });
  749. }
  750. // Success streak
  751. const sorted = [...archives]
  752. .filter(a => a.status === 'completed' || a.status === 'failed')
  753. .sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
  754. let streak = 0;
  755. for (const a of sorted) {
  756. if (a.status === 'completed') streak++;
  757. else break;
  758. }
  759. if (streak > 0) {
  760. result.push({
  761. icon: Zap,
  762. iconColor: 'text-yellow-400',
  763. label: t('stats.successStreak'),
  764. value: `${streak}`,
  765. detail: streak === 1 ? t('stats.streakPrint') : t('stats.streakPrints', { count: streak }),
  766. });
  767. }
  768. return result;
  769. }, [archives, currency, t]);
  770. if (records.length === 0) {
  771. return <p className="text-bambu-gray text-center py-4">{t('stats.noArchiveData')}</p>;
  772. }
  773. return (
  774. <div className="space-y-3">
  775. {records.map((record, i) => (
  776. <div key={i} className="flex items-center gap-3">
  777. <div className={`p-1.5 rounded-lg bg-bambu-dark ${record.iconColor}`}>
  778. <record.icon className="w-4 h-4" />
  779. </div>
  780. <div className="flex-1 min-w-0">
  781. <p className="text-xs text-bambu-gray">{record.label}</p>
  782. <div className="flex items-baseline gap-2">
  783. <span className="text-sm font-bold text-white">{record.value}</span>
  784. {record.detail && (
  785. <span className="text-xs text-bambu-gray truncate">{record.detail}</span>
  786. )}
  787. </div>
  788. </div>
  789. </div>
  790. ))}
  791. </div>
  792. );
  793. }
  794. export function StatsPage() {
  795. const { t } = useTranslation();
  796. const { showToast } = useToast();
  797. const { hasPermission } = useAuth();
  798. const [isExporting, setIsExporting] = useState(false);
  799. const [showExportMenu, setShowExportMenu] = useState(false);
  800. const [dashboardKey, setDashboardKey] = useState(0);
  801. const [hiddenCount, setHiddenCount] = useState(0);
  802. const [isRecalculating, setIsRecalculating] = useState(false);
  803. const [timeframe, setTimeframe] = useState<TimeframeState>({
  804. preset: 'all-time',
  805. dateFrom: undefined,
  806. dateTo: undefined,
  807. });
  808. const [showTimeframePicker, setShowTimeframePicker] = useState(false);
  809. const effectiveDateRange = useMemo(() => {
  810. if (timeframe.preset === 'custom') {
  811. return { dateFrom: timeframe.dateFrom, dateTo: timeframe.dateTo };
  812. }
  813. return computeDateRange(timeframe.preset);
  814. }, [timeframe]);
  815. // Read hidden count from localStorage
  816. useEffect(() => {
  817. const updateHiddenCount = () => {
  818. try {
  819. const saved = localStorage.getItem('bambusy-dashboard-layout-v2');
  820. if (saved) {
  821. const layout = JSON.parse(saved);
  822. setHiddenCount(layout.hidden?.length || 0);
  823. }
  824. } catch {
  825. setHiddenCount(0);
  826. }
  827. };
  828. updateHiddenCount();
  829. // Listen for storage changes
  830. window.addEventListener('storage', updateHiddenCount);
  831. // Also poll for changes (since storage event doesn't fire for same-tab changes)
  832. const interval = setInterval(updateHiddenCount, 2000);
  833. return () => {
  834. window.removeEventListener('storage', updateHiddenCount);
  835. clearInterval(interval);
  836. };
  837. }, [dashboardKey]);
  838. const { data: stats, isLoading, refetch: refetchStats } = useQuery({
  839. queryKey: ['archiveStats', effectiveDateRange.dateFrom, effectiveDateRange.dateTo],
  840. queryFn: () => api.getArchiveStats({
  841. dateFrom: effectiveDateRange.dateFrom,
  842. dateTo: effectiveDateRange.dateTo,
  843. }),
  844. });
  845. const { data: printers } = useQuery({
  846. queryKey: ['printers'],
  847. queryFn: api.getPrinters,
  848. });
  849. const { data: archives, refetch: refetchArchives } = useQuery({
  850. queryKey: ['archives', effectiveDateRange.dateFrom, effectiveDateRange.dateTo],
  851. queryFn: () => api.getArchives(undefined, undefined, 10000, 0, effectiveDateRange.dateFrom, effectiveDateRange.dateTo),
  852. });
  853. const { data: settings } = useQuery({
  854. queryKey: ['settings'],
  855. queryFn: api.getSettings,
  856. });
  857. const handleExport = async (format: 'csv' | 'xlsx') => {
  858. setShowExportMenu(false);
  859. setIsExporting(true);
  860. try {
  861. const { blob, filename } = await api.exportStats({ format, days: 90 });
  862. const url = URL.createObjectURL(blob);
  863. const a = document.createElement('a');
  864. a.href = url;
  865. a.download = filename;
  866. a.click();
  867. URL.revokeObjectURL(url);
  868. showToast(t('stats.exportDownloaded'));
  869. } catch {
  870. showToast(t('stats.exportFailed'), 'error');
  871. } finally {
  872. setIsExporting(false);
  873. }
  874. };
  875. const handleRecalculateCosts = async () => {
  876. setIsRecalculating(true);
  877. try {
  878. const result = await api.recalculateCosts();
  879. await Promise.all([refetchStats(), refetchArchives()]);
  880. showToast(t('stats.recalculatedCosts', { count: result.updated }));
  881. } catch {
  882. showToast(t('stats.recalculateFailed'), 'error');
  883. } finally {
  884. setIsRecalculating(false);
  885. }
  886. };
  887. const currency = getCurrencySymbol(settings?.currency || 'USD');
  888. const printerMap = new Map(printers?.map((p) => [String(p.id), p.name]) || []);
  889. const printDates = useMemo(() => archives?.map((a) => a.created_at) || [], [archives]);
  890. if (isLoading) {
  891. return (
  892. <div className="p-4 md:p-8">
  893. <div className="text-center py-12 text-bambu-gray">{t('stats.loadingStats')}</div>
  894. </div>
  895. );
  896. }
  897. // Define dashboard widgets
  898. // Sizes: 1 = quarter (1/4), 2 = half (1/2), 4 = full width
  899. // Widgets can use render functions to receive the current size for responsive content
  900. const widgets: DashboardWidget[] = [
  901. {
  902. id: 'quick-stats',
  903. title: t('stats.quickStats'),
  904. component: <QuickStatsWidget stats={stats} currency={currency} />,
  905. defaultSize: 2,
  906. },
  907. {
  908. id: 'success-rate',
  909. title: t('stats.successRate'),
  910. component: (size) => <SuccessRateWidget stats={stats} printerMap={printerMap} size={size} />,
  911. defaultSize: 1,
  912. },
  913. {
  914. id: 'time-accuracy',
  915. title: t('stats.timeAccuracy'),
  916. component: (size) => <TimeAccuracyWidget stats={stats} printerMap={printerMap} size={size} />,
  917. defaultSize: 1,
  918. },
  919. {
  920. id: 'failure-analysis',
  921. title: t('stats.failureAnalysis'),
  922. component: (size) => <FailureAnalysisWidget size={size} dateFrom={effectiveDateRange.dateFrom} dateTo={effectiveDateRange.dateTo} />,
  923. defaultSize: 1,
  924. },
  925. {
  926. id: 'print-activity',
  927. title: t('stats.printActivity'),
  928. component: (size) => <PrintActivityWidget printDates={printDates} size={size} />,
  929. defaultSize: 2,
  930. },
  931. {
  932. id: 'records',
  933. title: t('stats.records'),
  934. component: <RecordsWidget archives={archives || []} currency={currency} />,
  935. defaultSize: 1,
  936. },
  937. {
  938. id: 'printer-stats',
  939. title: t('stats.printerStats'),
  940. component: <PrinterStatsWidget stats={stats} archives={archives || []} printerMap={printerMap} />,
  941. defaultSize: 4,
  942. },
  943. {
  944. id: 'filament-trends',
  945. title: t('stats.filamentTrends'),
  946. component: <FilamentTrendsWidget archives={archives || []} currency={currency} />,
  947. defaultSize: 4,
  948. },
  949. ];
  950. return (
  951. <div className="p-4 md:p-8">
  952. <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
  953. <div>
  954. <h1 className="text-2xl font-bold text-white">{t('stats.title')}</h1>
  955. <p className="text-bambu-gray">{t('stats.subtitle')}</p>
  956. </div>
  957. <div className="flex items-center gap-2 flex-wrap">
  958. {/* Hidden widgets button - toggles panel in Dashboard */}
  959. {hiddenCount > 0 && (
  960. <Button
  961. variant="secondary"
  962. onClick={() => {
  963. // Toggle the hidden panel in Dashboard by triggering a custom event
  964. window.dispatchEvent(new CustomEvent('toggle-hidden-panel'));
  965. }}
  966. >
  967. <Eye className="w-4 h-4" />
  968. {t('stats.hiddenCount', { count: hiddenCount })}
  969. </Button>
  970. )}
  971. {/* Reset Layout */}
  972. <Button
  973. variant="secondary"
  974. onClick={() => {
  975. localStorage.removeItem('bambusy-dashboard-layout-v2');
  976. setDashboardKey(prev => prev + 1);
  977. showToast(t('stats.layoutReset'));
  978. }}
  979. disabled={!hasPermission('settings:update')}
  980. title={!hasPermission('settings:update') ? t('stats.noPermissionResetLayout') : undefined}
  981. >
  982. <RotateCcw className="w-4 h-4" />
  983. {t('stats.resetLayout')}
  984. </Button>
  985. {/* Recalculate Costs */}
  986. <Button
  987. variant="secondary"
  988. onClick={handleRecalculateCosts}
  989. disabled={isRecalculating || !hasPermission('archives:update_all')}
  990. title={!hasPermission('archives:update_all') ? t('stats.noPermissionRecalculate') : t('stats.recalculateCostsHint')}
  991. >
  992. {isRecalculating ? (
  993. <Loader2 className="w-4 h-4 animate-spin" />
  994. ) : (
  995. <Calculator className="w-4 h-4" />
  996. )}
  997. {t('stats.recalculateCosts')}
  998. </Button>
  999. {/* Export dropdown */}
  1000. <div className="relative">
  1001. <Button
  1002. variant="secondary"
  1003. onClick={() => setShowExportMenu(!showExportMenu)}
  1004. disabled={isExporting}
  1005. >
  1006. {isExporting ? (
  1007. <Loader2 className="w-4 h-4 animate-spin" />
  1008. ) : (
  1009. <FileSpreadsheet className="w-4 h-4" />
  1010. )}
  1011. {t('stats.exportStats')}
  1012. </Button>
  1013. {showExportMenu && (
  1014. <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">
  1015. <button
  1016. 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"
  1017. onClick={() => handleExport('csv')}
  1018. >
  1019. <FileText className="w-4 h-4" />
  1020. {t('stats.exportAsCsv')}
  1021. </button>
  1022. <button
  1023. 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"
  1024. onClick={() => handleExport('xlsx')}
  1025. >
  1026. <FileSpreadsheet className="w-4 h-4" />
  1027. {t('stats.exportAsExcel')}
  1028. </button>
  1029. </div>
  1030. )}
  1031. </div>
  1032. {/* Timeframe Selector */}
  1033. <div className="relative">
  1034. <Button
  1035. variant="secondary"
  1036. onClick={() => setShowTimeframePicker(!showTimeframePicker)}
  1037. >
  1038. <Calendar className="w-4 h-4" />
  1039. {t(`stats.timeframe.${timeframe.preset}`)}
  1040. <ChevronDown className="w-3 h-3" />
  1041. </Button>
  1042. {showTimeframePicker && (
  1043. <>
  1044. <div
  1045. className="fixed inset-0 z-10"
  1046. onClick={() => setShowTimeframePicker(false)}
  1047. />
  1048. <div className="absolute right-0 top-full mt-1 w-64 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-xl z-20 p-2">
  1049. {TIMEFRAME_PRESETS.map((preset) => (
  1050. <button
  1051. key={preset}
  1052. className={`w-full px-3 py-2 text-left text-sm rounded-md transition-colors ${
  1053. timeframe.preset === preset
  1054. ? 'bg-bambu-green text-white'
  1055. : 'text-white hover:bg-bambu-dark-tertiary'
  1056. }`}
  1057. onClick={() => {
  1058. setTimeframe({ preset, dateFrom: undefined, dateTo: undefined });
  1059. setShowTimeframePicker(false);
  1060. }}
  1061. >
  1062. {t(`stats.timeframe.${preset}`)}
  1063. </button>
  1064. ))}
  1065. <div className="border-t border-bambu-dark-tertiary my-2" />
  1066. <button
  1067. className={`w-full px-3 py-2 text-left text-sm rounded-md transition-colors ${
  1068. timeframe.preset === 'custom'
  1069. ? 'bg-bambu-green text-white'
  1070. : 'text-white hover:bg-bambu-dark-tertiary'
  1071. }`}
  1072. onClick={() => setTimeframe(prev => ({ ...prev, preset: 'custom' }))}
  1073. >
  1074. {t('stats.timeframe.custom')}
  1075. </button>
  1076. {timeframe.preset === 'custom' && (
  1077. <div className="mt-2 px-1 pb-1 space-y-2">
  1078. <div>
  1079. <label className="text-xs text-bambu-gray block mb-1">{t('stats.timeframe.from')}</label>
  1080. <input
  1081. type="date"
  1082. value={timeframe.dateFrom || ''}
  1083. max={timeframe.dateTo || new Date().toISOString().split('T')[0]}
  1084. onChange={(e) => setTimeframe(prev => ({ ...prev, dateFrom: e.target.value || undefined }))}
  1085. className="w-full bg-bambu-dark border border-bambu-dark-tertiary rounded-md px-3 py-1.5 text-sm text-white [color-scheme:dark]"
  1086. />
  1087. </div>
  1088. <div>
  1089. <label className="text-xs text-bambu-gray block mb-1">{t('stats.timeframe.to')}</label>
  1090. <input
  1091. type="date"
  1092. value={timeframe.dateTo || ''}
  1093. min={timeframe.dateFrom}
  1094. max={new Date().toISOString().split('T')[0]}
  1095. onChange={(e) => setTimeframe(prev => ({ ...prev, dateTo: e.target.value || undefined }))}
  1096. className="w-full bg-bambu-dark border border-bambu-dark-tertiary rounded-md px-3 py-1.5 text-sm text-white [color-scheme:dark]"
  1097. />
  1098. </div>
  1099. <Button
  1100. variant="primary"
  1101. onClick={() => setShowTimeframePicker(false)}
  1102. className="w-full"
  1103. >
  1104. {t('common.apply')}
  1105. </Button>
  1106. </div>
  1107. )}
  1108. </div>
  1109. </>
  1110. )}
  1111. </div>
  1112. </div>
  1113. </div>
  1114. <Dashboard
  1115. key={dashboardKey}
  1116. widgets={widgets}
  1117. storageKey="bambusy-dashboard-layout-v2"
  1118. stackBelow={640}
  1119. hideControls
  1120. />
  1121. </div>
  1122. );
  1123. }