|
|
@@ -1,5 +1,5 @@
|
|
|
import { useQuery } from '@tanstack/react-query';
|
|
|
-import { useState, useEffect } from 'react';
|
|
|
+import { useState, useEffect, useMemo } from 'react';
|
|
|
import { useTranslation } from 'react-i18next';
|
|
|
import {
|
|
|
Package,
|
|
|
@@ -7,7 +7,6 @@ import {
|
|
|
CheckCircle,
|
|
|
XCircle,
|
|
|
DollarSign,
|
|
|
- Printer,
|
|
|
Target,
|
|
|
Zap,
|
|
|
AlertTriangle,
|
|
|
@@ -18,21 +17,107 @@ import {
|
|
|
Eye,
|
|
|
RotateCcw,
|
|
|
Calculator,
|
|
|
+ Calendar,
|
|
|
+ ChevronDown,
|
|
|
} from 'lucide-react';
|
|
|
+import {
|
|
|
+ BarChart,
|
|
|
+ Bar,
|
|
|
+ XAxis,
|
|
|
+ YAxis,
|
|
|
+ CartesianGrid,
|
|
|
+ Tooltip,
|
|
|
+ ResponsiveContainer,
|
|
|
+} from 'recharts';
|
|
|
import { Button } from '../components/Button';
|
|
|
import { useToast } from '../contexts/ToastContext';
|
|
|
import { useAuth } from '../contexts/AuthContext';
|
|
|
-import { api } from '../api/client';
|
|
|
+import { api, type Archive } from '../api/client';
|
|
|
import { PrintCalendar } from '../components/PrintCalendar';
|
|
|
import { FilamentTrends } from '../components/FilamentTrends';
|
|
|
import { Dashboard, type DashboardWidget } from '../components/Dashboard';
|
|
|
import { getCurrencySymbol } from '../utils/currency';
|
|
|
+import { formatWeight } from '../utils/weight';
|
|
|
+import { parseUTCDate, formatDuration } from '../utils/date';
|
|
|
+import { MetricToggle, type Metric } from '../components/MetricToggle';
|
|
|
+
|
|
|
+// Timeframe types and helpers
|
|
|
+type TimeframePreset = 'today' | 'this-week' | 'this-month' | 'last-7' | 'last-30' | 'last-90' | 'this-year' | 'all-time' | 'custom';
|
|
|
+
|
|
|
+interface TimeframeState {
|
|
|
+ preset: TimeframePreset;
|
|
|
+ dateFrom: string | undefined; // YYYY-MM-DD
|
|
|
+ dateTo: string | undefined; // YYYY-MM-DD
|
|
|
+}
|
|
|
+
|
|
|
+function computeDateRange(preset: TimeframePreset): { dateFrom?: string; dateTo?: string } {
|
|
|
+ const now = new Date();
|
|
|
+ const y = now.getUTCFullYear(), m = now.getUTCMonth(), d = now.getUTCDate();
|
|
|
+ const fmt = (dt: Date) => dt.toISOString().split('T')[0];
|
|
|
+ const todayStr = fmt(now);
|
|
|
+
|
|
|
+ switch (preset) {
|
|
|
+ case 'today':
|
|
|
+ return { dateFrom: todayStr, dateTo: todayStr };
|
|
|
+ case 'this-week': {
|
|
|
+ const day = now.getUTCDay();
|
|
|
+ const start = new Date(Date.UTC(y, m, d - (day === 0 ? 6 : day - 1)));
|
|
|
+ return { dateFrom: fmt(start), dateTo: todayStr };
|
|
|
+ }
|
|
|
+ case 'this-month':
|
|
|
+ return { dateFrom: fmt(new Date(Date.UTC(y, m, 1))), dateTo: todayStr };
|
|
|
+ case 'last-7':
|
|
|
+ return { dateFrom: fmt(new Date(Date.UTC(y, m, d - 6))), dateTo: todayStr };
|
|
|
+ case 'last-30':
|
|
|
+ return { dateFrom: fmt(new Date(Date.UTC(y, m, d - 29))), dateTo: todayStr };
|
|
|
+ case 'last-90':
|
|
|
+ return { dateFrom: fmt(new Date(Date.UTC(y, m, d - 89))), dateTo: todayStr };
|
|
|
+ case 'this-year':
|
|
|
+ return { dateFrom: fmt(new Date(Date.UTC(y, 0, 1))), dateTo: todayStr };
|
|
|
+ case 'all-time':
|
|
|
+ return { dateFrom: undefined, dateTo: undefined };
|
|
|
+ case 'custom':
|
|
|
+ return {};
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+const TIMEFRAME_PRESETS: TimeframePreset[] = [
|
|
|
+ 'today', 'this-week', 'this-month',
|
|
|
+ 'last-7', 'last-30', 'last-90',
|
|
|
+ 'this-year', 'all-time',
|
|
|
+];
|
|
|
+
|
|
|
+// Constants
|
|
|
+const DAY_LABELS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
|
|
|
+
|
|
|
+const HOUR_LABELS = [
|
|
|
+ '12am', '1am', '2am', '3am', '4am', '5am',
|
|
|
+ '6am', '7am', '8am', '9am', '10am', '11am',
|
|
|
+ '12pm', '1pm', '2pm', '3pm', '4pm', '5pm',
|
|
|
+ '6pm', '7pm', '8pm', '9pm', '10pm', '11pm',
|
|
|
+];
|
|
|
+
|
|
|
+const DURATION_BUCKETS = [
|
|
|
+ { key: '<30m', max: 1800 },
|
|
|
+ { key: '30m-1h', max: 3600 },
|
|
|
+ { key: '1-2h', max: 7200 },
|
|
|
+ { key: '2-4h', max: 14400 },
|
|
|
+ { key: '4-8h', max: 28800 },
|
|
|
+ { key: '8-12h', max: 43200 },
|
|
|
+ { key: '12-24h', max: 86400 },
|
|
|
+ { key: '24h+', max: Infinity },
|
|
|
+];
|
|
|
+
|
|
|
+const RECHARTS_TOOLTIP_STYLE = {
|
|
|
+ backgroundColor: '#2d2d2d',
|
|
|
+ border: '1px solid #3d3d3d',
|
|
|
+ borderRadius: '8px',
|
|
|
+};
|
|
|
|
|
|
// Widget Components
|
|
|
function QuickStatsWidget({
|
|
|
stats,
|
|
|
currency,
|
|
|
- t,
|
|
|
}: {
|
|
|
stats: {
|
|
|
total_prints: number;
|
|
|
@@ -45,8 +130,8 @@ function QuickStatsWidget({
|
|
|
total_energy_cost: number;
|
|
|
} | undefined;
|
|
|
currency: string;
|
|
|
- t: (key: string) => string;
|
|
|
}) {
|
|
|
+ const { t } = useTranslation();
|
|
|
return (
|
|
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4">
|
|
|
<div className="flex items-start gap-3">
|
|
|
@@ -73,7 +158,7 @@ function QuickStatsWidget({
|
|
|
</div>
|
|
|
<div>
|
|
|
<p className="text-xs text-bambu-gray">{t('stats.filamentUsed')}</p>
|
|
|
- <p className="text-xl font-bold text-white">{((stats?.total_filament_grams || 0) / 1000).toFixed(2)}kg</p>
|
|
|
+ <p className="text-xl font-bold text-white">{formatWeight(stats?.total_filament_grams || 0)}</p>
|
|
|
</div>
|
|
|
</div>
|
|
|
<div className="flex items-start gap-3">
|
|
|
@@ -111,7 +196,6 @@ function SuccessRateWidget({
|
|
|
stats,
|
|
|
printerMap,
|
|
|
size = 1,
|
|
|
- t,
|
|
|
}: {
|
|
|
stats: {
|
|
|
total_prints: number;
|
|
|
@@ -121,8 +205,8 @@ function SuccessRateWidget({
|
|
|
} | undefined;
|
|
|
printerMap: Map<string, string>;
|
|
|
size?: 1 | 2 | 4;
|
|
|
- t: (key: string) => string;
|
|
|
}) {
|
|
|
+ const { t } = useTranslation();
|
|
|
const completedAndFailed = (stats?.successful_prints || 0) + (stats?.failed_prints || 0);
|
|
|
const successRate = completedAndFailed
|
|
|
? Math.round((stats!.successful_prints / completedAndFailed) * 100)
|
|
|
@@ -198,7 +282,6 @@ function TimeAccuracyWidget({
|
|
|
stats,
|
|
|
printerMap,
|
|
|
size = 1,
|
|
|
- t,
|
|
|
}: {
|
|
|
stats: {
|
|
|
average_time_accuracy: number | null;
|
|
|
@@ -206,8 +289,8 @@ function TimeAccuracyWidget({
|
|
|
} | undefined;
|
|
|
printerMap: Map<string, string>;
|
|
|
size?: 1 | 2 | 4;
|
|
|
- t: (key: string) => string;
|
|
|
}) {
|
|
|
+ const { t } = useTranslation();
|
|
|
const accuracy = stats?.average_time_accuracy;
|
|
|
|
|
|
if (accuracy === null || accuracy === undefined) {
|
|
|
@@ -300,85 +383,6 @@ function TimeAccuracyWidget({
|
|
|
);
|
|
|
}
|
|
|
|
|
|
-function FilamentTypesWidget({
|
|
|
- stats,
|
|
|
- size = 1,
|
|
|
- t,
|
|
|
-}: {
|
|
|
- stats: {
|
|
|
- total_prints: number;
|
|
|
- prints_by_filament_type: Record<string, number>;
|
|
|
- } | undefined;
|
|
|
- size?: 1 | 2 | 4;
|
|
|
- t: (key: string, options?: Record<string, unknown>) => string;
|
|
|
-}) {
|
|
|
- if (!stats?.prints_by_filament_type || Object.keys(stats.prints_by_filament_type).length === 0) {
|
|
|
- return <p className="text-bambu-gray text-center py-4">{t('stats.noFilamentData')}</p>;
|
|
|
- }
|
|
|
-
|
|
|
- // Sort by print count descending
|
|
|
- const sortedEntries = Object.entries(stats.prints_by_filament_type).sort(
|
|
|
- ([, a], [, b]) => b - a
|
|
|
- );
|
|
|
-
|
|
|
- // Limit entries based on size
|
|
|
- const maxEntries = size === 1 ? 5 : size === 2 ? 8 : 999;
|
|
|
- const displayEntries = sortedEntries.slice(0, maxEntries);
|
|
|
- const hasMore = sortedEntries.length > maxEntries;
|
|
|
-
|
|
|
- // Use grid layout when expanded
|
|
|
- if (size === 4 && displayEntries.length > 4) {
|
|
|
- return (
|
|
|
- <div className="grid grid-cols-2 lg:grid-cols-3 gap-4">
|
|
|
- {displayEntries.map(([type, count]) => {
|
|
|
- const percentage = Math.round((count / (stats.total_prints || 1)) * 100);
|
|
|
- return (
|
|
|
- <div key={type}>
|
|
|
- <div className="flex justify-between text-sm mb-1">
|
|
|
- <span className="text-white truncate max-w-[120px]">{type}</span>
|
|
|
- <span className="text-bambu-gray">{count}</span>
|
|
|
- </div>
|
|
|
- <div className="h-2 bg-bambu-dark rounded-full">
|
|
|
- <div
|
|
|
- className="h-full bg-bambu-green rounded-full transition-all"
|
|
|
- style={{ width: `${percentage}%` }}
|
|
|
- />
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- );
|
|
|
- })}
|
|
|
- </div>
|
|
|
- );
|
|
|
- }
|
|
|
-
|
|
|
- return (
|
|
|
- <div className="space-y-3">
|
|
|
- {displayEntries.map(([type, count]) => {
|
|
|
- const percentage = Math.round((count / (stats.total_prints || 1)) * 100);
|
|
|
- return (
|
|
|
- <div key={type}>
|
|
|
- <div className="flex justify-between text-sm mb-1">
|
|
|
- <span className="text-white">{type}</span>
|
|
|
- <span className="text-bambu-gray">{count} {t('common.prints')}</span>
|
|
|
- </div>
|
|
|
- <div className="h-2 bg-bambu-dark rounded-full">
|
|
|
- <div
|
|
|
- className="h-full bg-bambu-green rounded-full transition-all"
|
|
|
- style={{ width: `${percentage}%` }}
|
|
|
- />
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- );
|
|
|
- })}
|
|
|
- {hasMore && (
|
|
|
- <p className="text-xs text-bambu-gray text-center pt-1">
|
|
|
- {t('common.more', { count: sortedEntries.length - maxEntries })}
|
|
|
- </p>
|
|
|
- )}
|
|
|
- </div>
|
|
|
- );
|
|
|
-}
|
|
|
-
|
|
|
function PrintActivityWidget({
|
|
|
printDates,
|
|
|
size = 2,
|
|
|
@@ -391,34 +395,205 @@ function PrintActivityWidget({
|
|
|
return <PrintCalendar printDates={printDates} months={months} />;
|
|
|
}
|
|
|
|
|
|
-function PrintsByPrinterWidget({
|
|
|
+function PrinterStatsWidget({
|
|
|
stats,
|
|
|
+ archives,
|
|
|
printerMap,
|
|
|
- t,
|
|
|
}: {
|
|
|
stats: { prints_by_printer: Record<string, number> } | undefined;
|
|
|
+ archives: Archive[];
|
|
|
printerMap: Map<string, string>;
|
|
|
- t: (key: string) => string;
|
|
|
}) {
|
|
|
- if (!stats?.prints_by_printer || Object.keys(stats.prints_by_printer).length === 0) {
|
|
|
- return <p className="text-bambu-gray text-center py-4">{t('stats.noPrinterData')}</p>;
|
|
|
- }
|
|
|
+ const { t } = useTranslation();
|
|
|
+ const [printerMetric, setPrinterMetric] = useState<Metric>('weight');
|
|
|
+ const [habitsMetric, setHabitsMetric] = useState<Metric>('weight');
|
|
|
+
|
|
|
+ // Per-printer data
|
|
|
+ const printerData = useMemo(() => {
|
|
|
+ const map = new Map<string, { prints: number; weight: number; time: number }>();
|
|
|
+ if (stats?.prints_by_printer) {
|
|
|
+ Object.entries(stats.prints_by_printer).forEach(([id, count]) => {
|
|
|
+ const entry = map.get(id) || { prints: 0, weight: 0, time: 0 };
|
|
|
+ entry.prints = count;
|
|
|
+ map.set(id, entry);
|
|
|
+ });
|
|
|
+ }
|
|
|
+ archives.forEach(a => {
|
|
|
+ if (!a.printer_id) return;
|
|
|
+ const id = String(a.printer_id);
|
|
|
+ const entry = map.get(id) || { prints: 0, weight: 0, time: 0 };
|
|
|
+ entry.weight += a.filament_used_grams || 0;
|
|
|
+ entry.time += a.actual_time_seconds || a.print_time_seconds || 0;
|
|
|
+ if (!stats?.prints_by_printer) entry.prints++;
|
|
|
+ map.set(id, entry);
|
|
|
+ });
|
|
|
+ return Array.from(map.entries())
|
|
|
+ .map(([id, v]) => ({
|
|
|
+ name: printerMap.get(id) || `${t('common.printer')} ${id}`,
|
|
|
+ value: printerMetric === 'prints' ? v.prints :
|
|
|
+ printerMetric === 'weight' ? Math.round(v.weight) :
|
|
|
+ Math.round((v.time / 3600) * 10) / 10,
|
|
|
+ }))
|
|
|
+ .sort((a, b) => b.value - a.value);
|
|
|
+ }, [stats, archives, printerMap, printerMetric, t]);
|
|
|
+
|
|
|
+ // Hourly distribution (time of day)
|
|
|
+ const hourlyData = useMemo(() => {
|
|
|
+ const hours = Array.from({ length: 24 }, (_, i) => ({
|
|
|
+ hour: i,
|
|
|
+ label: HOUR_LABELS[i],
|
|
|
+ total: 0,
|
|
|
+ failures: 0,
|
|
|
+ }));
|
|
|
+
|
|
|
+ archives.forEach(a => {
|
|
|
+ if (!a.started_at) return;
|
|
|
+ const date = parseUTCDate(a.started_at);
|
|
|
+ if (!date) return;
|
|
|
+ const h = date.getHours();
|
|
|
+ hours[h].total++;
|
|
|
+ if (a.status === 'failed') {
|
|
|
+ hours[h].failures++;
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ return hours;
|
|
|
+ }, [archives]);
|
|
|
+
|
|
|
+ // Duration distribution
|
|
|
+ const durationData = useMemo(() => {
|
|
|
+ const counts = DURATION_BUCKETS.map(b => ({ name: b.key, count: 0 }));
|
|
|
+ archives.forEach(a => {
|
|
|
+ const seconds = a.actual_time_seconds || a.print_time_seconds;
|
|
|
+ if (!seconds || seconds <= 0) return;
|
|
|
+ for (let i = 0; i < DURATION_BUCKETS.length; i++) {
|
|
|
+ if (seconds <= DURATION_BUCKETS[i].max) {
|
|
|
+ counts[i].count++;
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ });
|
|
|
+ return counts;
|
|
|
+ }, [archives]);
|
|
|
+
|
|
|
+ // Habits (avg per day-of-week)
|
|
|
+ const habitsData = useMemo(() => {
|
|
|
+ const dayValues = [0, 0, 0, 0, 0, 0, 0];
|
|
|
+ const weeksSet = new Set<string>();
|
|
|
+ archives.forEach(a => {
|
|
|
+ const date = parseUTCDate(a.created_at) || new Date(a.created_at);
|
|
|
+ let day = date.getDay() - 1;
|
|
|
+ if (day < 0) day = 6;
|
|
|
+ if (habitsMetric === 'prints') dayValues[day]++;
|
|
|
+ else if (habitsMetric === 'weight') dayValues[day] += a.filament_used_grams || 0;
|
|
|
+ else dayValues[day] += (a.actual_time_seconds || a.print_time_seconds || 0) / 3600;
|
|
|
+ const weekStart = new Date(date);
|
|
|
+ weekStart.setDate(date.getDate() - ((date.getDay() + 6) % 7));
|
|
|
+ weeksSet.add(weekStart.toISOString().split('T')[0]);
|
|
|
+ });
|
|
|
+ const numWeeks = Math.max(weeksSet.size, 1);
|
|
|
+ return DAY_LABELS.map((name, i) => ({
|
|
|
+ name,
|
|
|
+ avg: Math.round((dayValues[i] / numWeeks) * 10) / 10,
|
|
|
+ }));
|
|
|
+ }, [archives, habitsMetric]);
|
|
|
+
|
|
|
+ const pUnit = printerMetric === 'weight' ? 'g' : printerMetric === 'time' ? 'h' : '';
|
|
|
+ const pLabel = printerMetric === 'weight' ? t('stats.filamentByWeight') : printerMetric === 'time' ? t('stats.hours') : t('common.prints');
|
|
|
+ const pColor = printerMetric === 'weight' ? '#00ae42' : printerMetric === 'time' ? '#3b82f6' : '#f59e0b';
|
|
|
+
|
|
|
+ const hUnit = habitsMetric === 'weight' ? 'g' : habitsMetric === 'time' ? 'h' : '';
|
|
|
+ const hLabel = habitsMetric === 'weight' ? t('stats.avgWeight') : habitsMetric === 'time' ? t('stats.avgTime') : t('stats.avgPrints');
|
|
|
+ const hColor = habitsMetric === 'weight' ? '#00ae42' : habitsMetric === 'time' ? '#3b82f6' : '#f59e0b';
|
|
|
|
|
|
return (
|
|
|
- <div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
|
|
- {Object.entries(stats.prints_by_printer).map(([printerId, count]) => (
|
|
|
- <div key={printerId} className="flex items-center gap-3 p-3 bg-bambu-dark rounded-lg">
|
|
|
- <div className="p-2 bg-bambu-dark-tertiary rounded-lg">
|
|
|
- <Printer className="w-4 h-4 text-bambu-green" />
|
|
|
- </div>
|
|
|
- <div>
|
|
|
- <p className="text-white font-medium text-sm">
|
|
|
- {printerMap.get(printerId) || `${t('common.printer')} ${printerId}`}
|
|
|
- </p>
|
|
|
- <p className="text-xs text-bambu-gray">{count} {t('common.prints')}</p>
|
|
|
+ <div className="space-y-4">
|
|
|
+ {/* By Printer */}
|
|
|
+ <div className="bg-bambu-dark rounded-lg p-4">
|
|
|
+ <div className="flex items-center justify-between mb-3">
|
|
|
+ <h4 className="text-sm font-medium text-bambu-gray">{t('stats.printsByPrinter')}</h4>
|
|
|
+ <MetricToggle value={printerMetric} onChange={setPrinterMetric} />
|
|
|
+ </div>
|
|
|
+ {printerData.length > 0 ? (
|
|
|
+ <ResponsiveContainer width="100%" height={Math.max(140, printerData.length * 40)}>
|
|
|
+ <BarChart data={printerData} layout="vertical" margin={{ left: 10 }}>
|
|
|
+ <CartesianGrid strokeDasharray="3 3" stroke="#3d3d3d" />
|
|
|
+ <XAxis type="number" stroke="#9ca3af" tick={{ fontSize: 11 }} unit={pUnit} />
|
|
|
+ <YAxis type="category" dataKey="name" stroke="#9ca3af" tick={{ fontSize: 11 }} width={100} />
|
|
|
+ <Tooltip
|
|
|
+ contentStyle={RECHARTS_TOOLTIP_STYLE}
|
|
|
+ formatter={(v: number | undefined) => [
|
|
|
+ printerMetric === 'weight' ? formatWeight(Number(v ?? 0)) : `${v ?? 0}${pUnit}`,
|
|
|
+ pLabel,
|
|
|
+ ]}
|
|
|
+ />
|
|
|
+ <Bar dataKey="value" fill={pColor} radius={[0, 4, 4, 0]} />
|
|
|
+ </BarChart>
|
|
|
+ </ResponsiveContainer>
|
|
|
+ ) : (
|
|
|
+ <p className="text-bambu-gray text-center py-4">{t('stats.noPrinterData')}</p>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
|
|
+ {/* Print Duration */}
|
|
|
+ <div className="bg-bambu-dark rounded-lg p-4">
|
|
|
+ <h4 className="text-sm font-medium text-bambu-gray mb-3">{t('stats.printDuration')}</h4>
|
|
|
+ {archives.length > 0 ? (
|
|
|
+ <ResponsiveContainer width="100%" height={160}>
|
|
|
+ <BarChart data={durationData}>
|
|
|
+ <CartesianGrid strokeDasharray="3 3" stroke="#3d3d3d" />
|
|
|
+ <XAxis dataKey="name" stroke="#9ca3af" tick={{ fontSize: 11 }} />
|
|
|
+ <YAxis stroke="#9ca3af" tick={{ fontSize: 11 }} allowDecimals={false} />
|
|
|
+ <Tooltip contentStyle={RECHARTS_TOOLTIP_STYLE} />
|
|
|
+ <Bar dataKey="count" name={t('common.prints')} fill="#00ae42" radius={[4, 4, 0, 0]} />
|
|
|
+ </BarChart>
|
|
|
+ </ResponsiveContainer>
|
|
|
+ ) : (
|
|
|
+ <p className="text-bambu-gray text-center py-4">{t('stats.noArchiveData')}</p>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* Print Habits */}
|
|
|
+ <div className="bg-bambu-dark rounded-lg p-4">
|
|
|
+ <div className="flex items-center justify-between mb-3">
|
|
|
+ <h4 className="text-sm font-medium text-bambu-gray">{t('stats.printHabits')}</h4>
|
|
|
+ <MetricToggle value={habitsMetric} onChange={setHabitsMetric} />
|
|
|
</div>
|
|
|
+ {archives.length > 0 ? (
|
|
|
+ <ResponsiveContainer width="100%" height={160}>
|
|
|
+ <BarChart data={habitsData}>
|
|
|
+ <CartesianGrid strokeDasharray="3 3" stroke="#3d3d3d" />
|
|
|
+ <XAxis dataKey="name" stroke="#9ca3af" tick={{ fontSize: 11 }} />
|
|
|
+ <YAxis stroke="#9ca3af" tick={{ fontSize: 11 }} unit={hUnit} />
|
|
|
+ <Tooltip contentStyle={RECHARTS_TOOLTIP_STYLE} formatter={(v: number | undefined) => [`${v ?? 0}${hUnit}`, hLabel]} />
|
|
|
+ <Bar dataKey="avg" fill={hColor} radius={[4, 4, 0, 0]} />
|
|
|
+ </BarChart>
|
|
|
+ </ResponsiveContainer>
|
|
|
+ ) : (
|
|
|
+ <p className="text-bambu-gray text-center py-4">{t('stats.noArchiveData')}</p>
|
|
|
+ )}
|
|
|
</div>
|
|
|
- ))}
|
|
|
+
|
|
|
+ {/* Print Time of Day */}
|
|
|
+ <div className="bg-bambu-dark rounded-lg p-4">
|
|
|
+ <h4 className="text-sm font-medium text-bambu-gray mb-3">{t('stats.printTimeOfDay')}</h4>
|
|
|
+ {archives.length > 0 ? (
|
|
|
+ <ResponsiveContainer width="100%" height={160}>
|
|
|
+ <BarChart data={hourlyData}>
|
|
|
+ <CartesianGrid strokeDasharray="3 3" stroke="#3d3d3d" />
|
|
|
+ <XAxis dataKey="label" stroke="#9ca3af" tick={{ fontSize: 10 }} interval={5} />
|
|
|
+ <YAxis stroke="#9ca3af" tick={{ fontSize: 11 }} allowDecimals={false} />
|
|
|
+ <Tooltip contentStyle={RECHARTS_TOOLTIP_STYLE} />
|
|
|
+ <Bar dataKey="total" name={t('stats.totalPrints')} fill="#00ae42" radius={[2, 2, 0, 0]} />
|
|
|
+ <Bar dataKey="failures" name={t('stats.failed')} fill="#ef4444" radius={[2, 2, 0, 0]} />
|
|
|
+ </BarChart>
|
|
|
+ </ResponsiveContainer>
|
|
|
+ ) : (
|
|
|
+ <p className="text-bambu-gray text-center py-4">{t('stats.noArchiveData')}</p>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
</div>
|
|
|
);
|
|
|
}
|
|
|
@@ -426,22 +601,29 @@ function PrintsByPrinterWidget({
|
|
|
function FilamentTrendsWidget({
|
|
|
archives,
|
|
|
currency,
|
|
|
- t,
|
|
|
}: {
|
|
|
archives: Parameters<typeof FilamentTrends>[0]['archives'];
|
|
|
currency: string;
|
|
|
- t: (key: string) => string;
|
|
|
}) {
|
|
|
+ const { t } = useTranslation();
|
|
|
if (!archives || archives.length === 0) {
|
|
|
return <p className="text-bambu-gray text-center py-4">{t('stats.noPrintData')}</p>;
|
|
|
}
|
|
|
return <FilamentTrends archives={archives} currency={currency} />;
|
|
|
}
|
|
|
|
|
|
-function FailureAnalysisWidget({ size = 1, t }: { size?: 1 | 2 | 4; t: (key: string, options?: Record<string, unknown>) => string }) {
|
|
|
+function FailureAnalysisWidget({ size = 1, dateFrom, dateTo }: {
|
|
|
+ size?: 1 | 2 | 4;
|
|
|
+ dateFrom?: string;
|
|
|
+ dateTo?: string;
|
|
|
+}) {
|
|
|
+ const { t } = useTranslation();
|
|
|
+ const hasDateRange = !!(dateFrom || dateTo);
|
|
|
const { data: analysis, isLoading } = useQuery({
|
|
|
- queryKey: ['failureAnalysis'],
|
|
|
- queryFn: () => api.getFailureAnalysis({ days: 30 }),
|
|
|
+ queryKey: ['failureAnalysis', dateFrom, dateTo],
|
|
|
+ queryFn: () => api.getFailureAnalysis({
|
|
|
+ ...(hasDateRange ? { dateFrom, dateTo } : { days: 30 }),
|
|
|
+ }),
|
|
|
});
|
|
|
|
|
|
if (isLoading) {
|
|
|
@@ -453,7 +635,7 @@ function FailureAnalysisWidget({ size = 1, t }: { size?: 1 | 2 | 4; t: (key: str
|
|
|
}
|
|
|
|
|
|
if (!analysis || analysis.total_prints === 0) {
|
|
|
- return <p className="text-bambu-gray text-center py-4">{t('stats.noPrintDataLast30Days')}</p>;
|
|
|
+ return <p className="text-bambu-gray text-center py-4">{hasDateRange ? t('stats.noPrintDataInRange') : t('stats.noPrintDataLast30Days')}</p>;
|
|
|
}
|
|
|
|
|
|
// Show more reasons when expanded
|
|
|
@@ -519,6 +701,150 @@ function FailureAnalysisWidget({ size = 1, t }: { size?: 1 | 2 | 4; t: (key: str
|
|
|
);
|
|
|
}
|
|
|
|
|
|
+function RecordsWidget({ archives, currency }: { archives: Archive[]; currency: string }) {
|
|
|
+ const { t } = useTranslation();
|
|
|
+
|
|
|
+ const records = useMemo(() => {
|
|
|
+ const result: Array<{
|
|
|
+ icon: typeof Clock;
|
|
|
+ iconColor: string;
|
|
|
+ label: string;
|
|
|
+ value: string;
|
|
|
+ detail: string | null;
|
|
|
+ }> = [];
|
|
|
+
|
|
|
+ if (archives.length === 0) return result;
|
|
|
+
|
|
|
+ // Longest print
|
|
|
+ let longestArchive: Archive | null = null;
|
|
|
+ let longestTime = 0;
|
|
|
+ archives.forEach(a => {
|
|
|
+ if (a.actual_time_seconds && a.actual_time_seconds > longestTime) {
|
|
|
+ longestTime = a.actual_time_seconds;
|
|
|
+ longestArchive = a;
|
|
|
+ }
|
|
|
+ });
|
|
|
+ if (longestArchive && longestTime > 0) {
|
|
|
+ result.push({
|
|
|
+ icon: Clock,
|
|
|
+ iconColor: 'text-blue-400',
|
|
|
+ label: t('stats.longestPrint'),
|
|
|
+ value: formatDuration(longestTime),
|
|
|
+ detail: (longestArchive as Archive).print_name || null,
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ // Heaviest print
|
|
|
+ let heaviestArchive: Archive | null = null;
|
|
|
+ let heaviestWeight = 0;
|
|
|
+ archives.forEach(a => {
|
|
|
+ if (a.filament_used_grams && a.filament_used_grams > heaviestWeight) {
|
|
|
+ heaviestWeight = a.filament_used_grams;
|
|
|
+ heaviestArchive = a;
|
|
|
+ }
|
|
|
+ });
|
|
|
+ if (heaviestArchive && heaviestWeight > 0) {
|
|
|
+ result.push({
|
|
|
+ icon: Package,
|
|
|
+ iconColor: 'text-orange-400',
|
|
|
+ label: t('stats.heaviestPrint'),
|
|
|
+ value: formatWeight(heaviestWeight),
|
|
|
+ detail: (heaviestArchive as Archive).print_name || null,
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ // Most expensive print
|
|
|
+ let costliestArchive: Archive | null = null;
|
|
|
+ let highestCost = 0;
|
|
|
+ archives.forEach(a => {
|
|
|
+ if (a.cost && a.cost > highestCost) {
|
|
|
+ highestCost = a.cost;
|
|
|
+ costliestArchive = a;
|
|
|
+ }
|
|
|
+ });
|
|
|
+ if (costliestArchive && highestCost > 0) {
|
|
|
+ result.push({
|
|
|
+ icon: DollarSign,
|
|
|
+ iconColor: 'text-green-400',
|
|
|
+ label: t('stats.mostExpensivePrint'),
|
|
|
+ value: `${currency}${highestCost.toFixed(2)}`,
|
|
|
+ detail: (costliestArchive as Archive).print_name || null,
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ // Busiest day
|
|
|
+ const dayCounts = new Map<string, number>();
|
|
|
+ archives.forEach(a => {
|
|
|
+ const date = parseUTCDate(a.created_at) || new Date(a.created_at);
|
|
|
+ const key = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
|
|
+ dayCounts.set(key, (dayCounts.get(key) || 0) + 1);
|
|
|
+ });
|
|
|
+ let busiestDay = '';
|
|
|
+ let busiestCount = 0;
|
|
|
+ dayCounts.forEach((count, day) => {
|
|
|
+ if (count > busiestCount) {
|
|
|
+ busiestCount = count;
|
|
|
+ busiestDay = day;
|
|
|
+ }
|
|
|
+ });
|
|
|
+ if (busiestCount > 1) {
|
|
|
+ result.push({
|
|
|
+ icon: Calendar,
|
|
|
+ iconColor: 'text-purple-400',
|
|
|
+ label: t('stats.busiestDay'),
|
|
|
+ value: `${busiestCount} ${t('common.prints')}`,
|
|
|
+ detail: new Date(busiestDay).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }),
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ // Success streak
|
|
|
+ const sorted = [...archives]
|
|
|
+ .filter(a => a.status === 'completed' || a.status === 'failed')
|
|
|
+ .sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
|
|
|
+ let streak = 0;
|
|
|
+ for (const a of sorted) {
|
|
|
+ if (a.status === 'completed') streak++;
|
|
|
+ else break;
|
|
|
+ }
|
|
|
+ if (streak > 0) {
|
|
|
+ result.push({
|
|
|
+ icon: Zap,
|
|
|
+ iconColor: 'text-yellow-400',
|
|
|
+ label: t('stats.successStreak'),
|
|
|
+ value: `${streak}`,
|
|
|
+ detail: streak === 1 ? t('stats.streakPrint') : t('stats.streakPrints', { count: streak }),
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ return result;
|
|
|
+ }, [archives, currency, t]);
|
|
|
+
|
|
|
+ if (records.length === 0) {
|
|
|
+ return <p className="text-bambu-gray text-center py-4">{t('stats.noArchiveData')}</p>;
|
|
|
+ }
|
|
|
+
|
|
|
+ return (
|
|
|
+ <div className="space-y-3">
|
|
|
+ {records.map((record, i) => (
|
|
|
+ <div key={i} className="flex items-center gap-3">
|
|
|
+ <div className={`p-1.5 rounded-lg bg-bambu-dark ${record.iconColor}`}>
|
|
|
+ <record.icon className="w-4 h-4" />
|
|
|
+ </div>
|
|
|
+ <div className="flex-1 min-w-0">
|
|
|
+ <p className="text-xs text-bambu-gray">{record.label}</p>
|
|
|
+ <div className="flex items-baseline gap-2">
|
|
|
+ <span className="text-sm font-bold text-white">{record.value}</span>
|
|
|
+ {record.detail && (
|
|
|
+ <span className="text-xs text-bambu-gray truncate">{record.detail}</span>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ ))}
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
export function StatsPage() {
|
|
|
const { t } = useTranslation();
|
|
|
const { showToast } = useToast();
|
|
|
@@ -528,12 +854,25 @@ export function StatsPage() {
|
|
|
const [dashboardKey, setDashboardKey] = useState(0);
|
|
|
const [hiddenCount, setHiddenCount] = useState(0);
|
|
|
const [isRecalculating, setIsRecalculating] = useState(false);
|
|
|
+ const [timeframe, setTimeframe] = useState<TimeframeState>({
|
|
|
+ preset: 'all-time',
|
|
|
+ dateFrom: undefined,
|
|
|
+ dateTo: undefined,
|
|
|
+ });
|
|
|
+ const [showTimeframePicker, setShowTimeframePicker] = useState(false);
|
|
|
+
|
|
|
+ const effectiveDateRange = useMemo(() => {
|
|
|
+ if (timeframe.preset === 'custom') {
|
|
|
+ return { dateFrom: timeframe.dateFrom, dateTo: timeframe.dateTo };
|
|
|
+ }
|
|
|
+ return computeDateRange(timeframe.preset);
|
|
|
+ }, [timeframe]);
|
|
|
|
|
|
// Read hidden count from localStorage
|
|
|
useEffect(() => {
|
|
|
const updateHiddenCount = () => {
|
|
|
try {
|
|
|
- const saved = localStorage.getItem('bambusy-dashboard-layout');
|
|
|
+ const saved = localStorage.getItem('bambusy-dashboard-layout-v2');
|
|
|
if (saved) {
|
|
|
const layout = JSON.parse(saved);
|
|
|
setHiddenCount(layout.hidden?.length || 0);
|
|
|
@@ -546,7 +885,7 @@ export function StatsPage() {
|
|
|
// Listen for storage changes
|
|
|
window.addEventListener('storage', updateHiddenCount);
|
|
|
// Also poll for changes (since storage event doesn't fire for same-tab changes)
|
|
|
- const interval = setInterval(updateHiddenCount, 500);
|
|
|
+ const interval = setInterval(updateHiddenCount, 2000);
|
|
|
return () => {
|
|
|
window.removeEventListener('storage', updateHiddenCount);
|
|
|
clearInterval(interval);
|
|
|
@@ -554,8 +893,11 @@ export function StatsPage() {
|
|
|
}, [dashboardKey]);
|
|
|
|
|
|
const { data: stats, isLoading, refetch: refetchStats } = useQuery({
|
|
|
- queryKey: ['archiveStats'],
|
|
|
- queryFn: api.getArchiveStats,
|
|
|
+ queryKey: ['archiveStats', effectiveDateRange.dateFrom, effectiveDateRange.dateTo],
|
|
|
+ queryFn: () => api.getArchiveStats({
|
|
|
+ dateFrom: effectiveDateRange.dateFrom,
|
|
|
+ dateTo: effectiveDateRange.dateTo,
|
|
|
+ }),
|
|
|
});
|
|
|
|
|
|
const { data: printers } = useQuery({
|
|
|
@@ -563,9 +905,9 @@ export function StatsPage() {
|
|
|
queryFn: api.getPrinters,
|
|
|
});
|
|
|
|
|
|
- const { data: archives } = useQuery({
|
|
|
- queryKey: ['archives'],
|
|
|
- queryFn: () => api.getArchives(undefined, undefined, 1000, 0),
|
|
|
+ const { data: archives, refetch: refetchArchives } = useQuery({
|
|
|
+ queryKey: ['archives', effectiveDateRange.dateFrom, effectiveDateRange.dateTo],
|
|
|
+ queryFn: () => api.getArchives(undefined, undefined, 10000, 0, effectiveDateRange.dateFrom, effectiveDateRange.dateTo),
|
|
|
});
|
|
|
|
|
|
const { data: settings } = useQuery({
|
|
|
@@ -596,7 +938,7 @@ export function StatsPage() {
|
|
|
setIsRecalculating(true);
|
|
|
try {
|
|
|
const result = await api.recalculateCosts();
|
|
|
- await refetchStats();
|
|
|
+ await Promise.all([refetchStats(), refetchArchives()]);
|
|
|
showToast(t('stats.recalculatedCosts', { count: result.updated }));
|
|
|
} catch {
|
|
|
showToast(t('stats.recalculateFailed'), 'error');
|
|
|
@@ -607,7 +949,7 @@ export function StatsPage() {
|
|
|
|
|
|
const currency = getCurrencySymbol(settings?.currency || 'USD');
|
|
|
const printerMap = new Map(printers?.map((p) => [String(p.id), p.name]) || []);
|
|
|
- const printDates = archives?.map((a) => a.created_at) || [];
|
|
|
+ const printDates = useMemo(() => archives?.map((a) => a.created_at) || [], [archives]);
|
|
|
|
|
|
if (isLoading) {
|
|
|
return (
|
|
|
@@ -624,31 +966,25 @@ export function StatsPage() {
|
|
|
{
|
|
|
id: 'quick-stats',
|
|
|
title: t('stats.quickStats'),
|
|
|
- component: <QuickStatsWidget stats={stats} currency={currency} t={t} />,
|
|
|
+ component: <QuickStatsWidget stats={stats} currency={currency} />,
|
|
|
defaultSize: 2,
|
|
|
},
|
|
|
{
|
|
|
id: 'success-rate',
|
|
|
title: t('stats.successRate'),
|
|
|
- component: (size) => <SuccessRateWidget stats={stats} printerMap={printerMap} size={size} t={t} />,
|
|
|
+ component: (size) => <SuccessRateWidget stats={stats} printerMap={printerMap} size={size} />,
|
|
|
defaultSize: 1,
|
|
|
},
|
|
|
{
|
|
|
id: 'time-accuracy',
|
|
|
title: t('stats.timeAccuracy'),
|
|
|
- component: (size) => <TimeAccuracyWidget stats={stats} printerMap={printerMap} size={size} t={t} />,
|
|
|
- defaultSize: 1,
|
|
|
- },
|
|
|
- {
|
|
|
- id: 'filament-types',
|
|
|
- title: t('stats.filamentTypes'),
|
|
|
- component: (size) => <FilamentTypesWidget stats={stats} size={size} t={t} />,
|
|
|
+ component: (size) => <TimeAccuracyWidget stats={stats} printerMap={printerMap} size={size} />,
|
|
|
defaultSize: 1,
|
|
|
},
|
|
|
{
|
|
|
id: 'failure-analysis',
|
|
|
title: t('stats.failureAnalysis'),
|
|
|
- component: (size) => <FailureAnalysisWidget size={size} t={t} />,
|
|
|
+ component: (size) => <FailureAnalysisWidget size={size} dateFrom={effectiveDateRange.dateFrom} dateTo={effectiveDateRange.dateTo} />,
|
|
|
defaultSize: 1,
|
|
|
},
|
|
|
{
|
|
|
@@ -658,20 +994,25 @@ export function StatsPage() {
|
|
|
defaultSize: 2,
|
|
|
},
|
|
|
{
|
|
|
- id: 'prints-by-printer',
|
|
|
- title: t('stats.printsByPrinter'),
|
|
|
- component: <PrintsByPrinterWidget stats={stats} printerMap={printerMap} t={t} />,
|
|
|
- defaultSize: 2,
|
|
|
+ id: 'records',
|
|
|
+ title: t('stats.records'),
|
|
|
+ component: <RecordsWidget archives={archives || []} currency={currency} />,
|
|
|
+ defaultSize: 1,
|
|
|
+ },
|
|
|
+ {
|
|
|
+ id: 'printer-stats',
|
|
|
+ title: t('stats.printerStats'),
|
|
|
+ component: <PrinterStatsWidget stats={stats} archives={archives || []} printerMap={printerMap} />,
|
|
|
+ defaultSize: 4,
|
|
|
},
|
|
|
{
|
|
|
id: 'filament-trends',
|
|
|
title: t('stats.filamentTrends'),
|
|
|
- component: <FilamentTrendsWidget archives={archives || []} currency={currency} t={t} />,
|
|
|
+ component: <FilamentTrendsWidget archives={archives || []} currency={currency} />,
|
|
|
defaultSize: 4,
|
|
|
},
|
|
|
];
|
|
|
|
|
|
-
|
|
|
return (
|
|
|
<div className="p-4 md:p-8">
|
|
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
|
|
|
@@ -697,7 +1038,7 @@ export function StatsPage() {
|
|
|
<Button
|
|
|
variant="secondary"
|
|
|
onClick={() => {
|
|
|
- localStorage.removeItem('bambusy-dashboard-layout');
|
|
|
+ localStorage.removeItem('bambusy-dashboard-layout-v2');
|
|
|
setDashboardKey(prev => prev + 1);
|
|
|
showToast(t('stats.layoutReset'));
|
|
|
}}
|
|
|
@@ -754,13 +1095,97 @@ export function StatsPage() {
|
|
|
</div>
|
|
|
)}
|
|
|
</div>
|
|
|
+ {/* Timeframe Selector */}
|
|
|
+ <div className="relative">
|
|
|
+ <Button
|
|
|
+ variant="secondary"
|
|
|
+ onClick={() => setShowTimeframePicker(!showTimeframePicker)}
|
|
|
+ >
|
|
|
+ <Calendar className="w-4 h-4" />
|
|
|
+ {t(`stats.timeframe.${timeframe.preset}`)}
|
|
|
+ <ChevronDown className="w-3 h-3" />
|
|
|
+ </Button>
|
|
|
+
|
|
|
+ {showTimeframePicker && (
|
|
|
+ <>
|
|
|
+ <div
|
|
|
+ className="fixed inset-0 z-10"
|
|
|
+ onClick={() => setShowTimeframePicker(false)}
|
|
|
+ />
|
|
|
+ <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">
|
|
|
+ {TIMEFRAME_PRESETS.map((preset) => (
|
|
|
+ <button
|
|
|
+ key={preset}
|
|
|
+ className={`w-full px-3 py-2 text-left text-sm rounded-md transition-colors ${
|
|
|
+ timeframe.preset === preset
|
|
|
+ ? 'bg-bambu-green text-white'
|
|
|
+ : 'text-white hover:bg-bambu-dark-tertiary'
|
|
|
+ }`}
|
|
|
+ onClick={() => {
|
|
|
+ setTimeframe({ preset, dateFrom: undefined, dateTo: undefined });
|
|
|
+ setShowTimeframePicker(false);
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ {t(`stats.timeframe.${preset}`)}
|
|
|
+ </button>
|
|
|
+ ))}
|
|
|
+
|
|
|
+ <div className="border-t border-bambu-dark-tertiary my-2" />
|
|
|
+
|
|
|
+ <button
|
|
|
+ className={`w-full px-3 py-2 text-left text-sm rounded-md transition-colors ${
|
|
|
+ timeframe.preset === 'custom'
|
|
|
+ ? 'bg-bambu-green text-white'
|
|
|
+ : 'text-white hover:bg-bambu-dark-tertiary'
|
|
|
+ }`}
|
|
|
+ onClick={() => setTimeframe(prev => ({ ...prev, preset: 'custom' }))}
|
|
|
+ >
|
|
|
+ {t('stats.timeframe.custom')}
|
|
|
+ </button>
|
|
|
+
|
|
|
+ {timeframe.preset === 'custom' && (
|
|
|
+ <div className="mt-2 px-1 pb-1 space-y-2">
|
|
|
+ <div>
|
|
|
+ <label className="text-xs text-bambu-gray block mb-1">{t('stats.timeframe.from')}</label>
|
|
|
+ <input
|
|
|
+ type="date"
|
|
|
+ value={timeframe.dateFrom || ''}
|
|
|
+ max={timeframe.dateTo || new Date().toISOString().split('T')[0]}
|
|
|
+ onChange={(e) => setTimeframe(prev => ({ ...prev, dateFrom: e.target.value || undefined }))}
|
|
|
+ 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]"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ <div>
|
|
|
+ <label className="text-xs text-bambu-gray block mb-1">{t('stats.timeframe.to')}</label>
|
|
|
+ <input
|
|
|
+ type="date"
|
|
|
+ value={timeframe.dateTo || ''}
|
|
|
+ min={timeframe.dateFrom}
|
|
|
+ max={new Date().toISOString().split('T')[0]}
|
|
|
+ onChange={(e) => setTimeframe(prev => ({ ...prev, dateTo: e.target.value || undefined }))}
|
|
|
+ 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]"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ <Button
|
|
|
+ variant="primary"
|
|
|
+ onClick={() => setShowTimeframePicker(false)}
|
|
|
+ className="w-full"
|
|
|
+ >
|
|
|
+ {t('common.apply')}
|
|
|
+ </Button>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ </>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
</div>
|
|
|
</div>
|
|
|
|
|
|
<Dashboard
|
|
|
key={dashboardKey}
|
|
|
widgets={widgets}
|
|
|
- storageKey="bambusy-dashboard-layout"
|
|
|
+ storageKey="bambusy-dashboard-layout-v2"
|
|
|
stackBelow={640}
|
|
|
hideControls
|
|
|
/>
|