StatsPage.tsx 42 KB

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