|
|
@@ -19,6 +19,7 @@ import {
|
|
|
Calculator,
|
|
|
Calendar,
|
|
|
ChevronDown,
|
|
|
+ Users,
|
|
|
} from 'lucide-react';
|
|
|
import {
|
|
|
BarChart,
|
|
|
@@ -706,17 +707,19 @@ function FilamentTrendsWidget({
|
|
|
return <FilamentTrends archives={archives} currency={currency} dateFrom={dateFrom} dateTo={dateTo} />;
|
|
|
}
|
|
|
|
|
|
-function FailureAnalysisWidget({ size = 1, dateFrom, dateTo }: {
|
|
|
+function FailureAnalysisWidget({ size = 1, dateFrom, dateTo, createdById }: {
|
|
|
size?: 1 | 2 | 4;
|
|
|
dateFrom?: string;
|
|
|
dateTo?: string;
|
|
|
+ createdById?: number;
|
|
|
}) {
|
|
|
const { t } = useTranslation();
|
|
|
const hasDateRange = !!(dateFrom || dateTo);
|
|
|
const { data: analysis, isLoading } = useQuery({
|
|
|
- queryKey: ['failureAnalysis', dateFrom, dateTo],
|
|
|
+ queryKey: ['failureAnalysis', dateFrom, dateTo, createdById ?? 'all'],
|
|
|
queryFn: () => api.getFailureAnalysis({
|
|
|
...(hasDateRange ? { dateFrom, dateTo } : { days: 30 }),
|
|
|
+ createdById,
|
|
|
}),
|
|
|
});
|
|
|
|
|
|
@@ -923,12 +926,15 @@ function RecordsWidget({ archives, currency }: { archives: ArchiveSlim[]; curren
|
|
|
export function StatsPage() {
|
|
|
const { t } = useTranslation();
|
|
|
const { showToast } = useToast();
|
|
|
- const { hasPermission } = useAuth();
|
|
|
+ const { hasPermission, authEnabled } = useAuth();
|
|
|
const [isExporting, setIsExporting] = useState(false);
|
|
|
const [showExportMenu, setShowExportMenu] = useState(false);
|
|
|
const [dashboardKey, setDashboardKey] = useState(0);
|
|
|
const [hiddenCount, setHiddenCount] = useState(0);
|
|
|
const [isRecalculating, setIsRecalculating] = useState(false);
|
|
|
+ const [selectedUserId, setSelectedUserId] = useState<number | null>(null);
|
|
|
+ const [showUserPicker, setShowUserPicker] = useState(false);
|
|
|
+ const canFilterByUser = authEnabled && hasPermission('stats:filter_by_user');
|
|
|
const [timeframe, setTimeframe] = useState<TimeframeState>(() => {
|
|
|
try {
|
|
|
const saved = localStorage.getItem('bambusy-stats-timeframe');
|
|
|
@@ -977,11 +983,15 @@ export function StatsPage() {
|
|
|
};
|
|
|
}, [dashboardKey]);
|
|
|
|
|
|
- const { data: stats, isLoading, refetch: refetchStats } = useQuery({
|
|
|
- queryKey: ['archiveStats', effectiveDateRange.dateFrom, effectiveDateRange.dateTo],
|
|
|
+ // Only pass createdById when a user is actually selected (not "All Users")
|
|
|
+ const createdByIdParam = selectedUserId !== null ? selectedUserId : undefined;
|
|
|
+
|
|
|
+ const { data: stats, isLoading, isFetching: isStatsFetching, refetch: refetchStats } = useQuery({
|
|
|
+ queryKey: ['archiveStats', effectiveDateRange.dateFrom, effectiveDateRange.dateTo, createdByIdParam ?? 'all'],
|
|
|
queryFn: () => api.getArchiveStats({
|
|
|
dateFrom: effectiveDateRange.dateFrom,
|
|
|
dateTo: effectiveDateRange.dateTo,
|
|
|
+ createdById: createdByIdParam,
|
|
|
}),
|
|
|
});
|
|
|
|
|
|
@@ -990,9 +1000,9 @@ export function StatsPage() {
|
|
|
queryFn: api.getPrinters,
|
|
|
});
|
|
|
|
|
|
- const { data: archives, refetch: refetchArchives } = useQuery({
|
|
|
- queryKey: ['archivesSlim', effectiveDateRange.dateFrom, effectiveDateRange.dateTo],
|
|
|
- queryFn: () => api.getArchivesSlim(effectiveDateRange.dateFrom, effectiveDateRange.dateTo),
|
|
|
+ const { data: archives, isFetching: isArchivesFetching, refetch: refetchArchives } = useQuery({
|
|
|
+ queryKey: ['archivesSlim', effectiveDateRange.dateFrom, effectiveDateRange.dateTo, createdByIdParam ?? 'all'],
|
|
|
+ queryFn: () => api.getArchivesSlim(effectiveDateRange.dateFrom, effectiveDateRange.dateTo, createdByIdParam),
|
|
|
});
|
|
|
|
|
|
const { data: settings } = useQuery({
|
|
|
@@ -1000,11 +1010,23 @@ export function StatsPage() {
|
|
|
queryFn: api.getSettings,
|
|
|
});
|
|
|
|
|
|
+ const { data: users } = useQuery({
|
|
|
+ queryKey: ['users'],
|
|
|
+ queryFn: api.getUsers,
|
|
|
+ enabled: canFilterByUser,
|
|
|
+ });
|
|
|
+
|
|
|
+ const selectedUserLabel = useMemo(() => {
|
|
|
+ if (selectedUserId === null) return t('stats.allUsers', 'All Users');
|
|
|
+ if (selectedUserId === -1) return t('stats.noUser', 'No User (System)');
|
|
|
+ return users?.find(u => u.id === selectedUserId)?.username ?? '?';
|
|
|
+ }, [selectedUserId, users, t]);
|
|
|
+
|
|
|
const handleExport = async (format: 'csv' | 'xlsx') => {
|
|
|
setShowExportMenu(false);
|
|
|
setIsExporting(true);
|
|
|
try {
|
|
|
- const { blob, filename } = await api.exportStats({ format, days: 90 });
|
|
|
+ const { blob, filename } = await api.exportStats({ format, days: 90, createdById: createdByIdParam });
|
|
|
const url = URL.createObjectURL(blob);
|
|
|
const a = document.createElement('a');
|
|
|
a.href = url;
|
|
|
@@ -1032,6 +1054,8 @@ export function StatsPage() {
|
|
|
}
|
|
|
};
|
|
|
|
|
|
+ const isRefetching = (isStatsFetching || isArchivesFetching) && !isLoading;
|
|
|
+
|
|
|
const currency = getCurrencySymbol(settings?.currency || 'USD');
|
|
|
const printerMap = new Map(printers?.map((p) => [String(p.id), p.name]) || []);
|
|
|
const printDates = useMemo(() => archives?.map((a) => a.created_at) || [], [archives]);
|
|
|
@@ -1069,7 +1093,7 @@ export function StatsPage() {
|
|
|
{
|
|
|
id: 'failure-analysis',
|
|
|
title: t('stats.failureAnalysis'),
|
|
|
- component: (size) => <FailureAnalysisWidget size={size} dateFrom={effectiveDateRange.dateFrom} dateTo={effectiveDateRange.dateTo} />,
|
|
|
+ component: (size) => <FailureAnalysisWidget size={size} dateFrom={effectiveDateRange.dateFrom} dateTo={effectiveDateRange.dateTo} createdById={createdByIdParam} />,
|
|
|
defaultSize: 1,
|
|
|
},
|
|
|
{
|
|
|
@@ -1102,7 +1126,10 @@ export function StatsPage() {
|
|
|
<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">
|
|
|
<div>
|
|
|
- <h1 className="text-2xl font-bold text-white">{t('stats.title')}</h1>
|
|
|
+ <div className="flex items-center gap-2">
|
|
|
+ <h1 className="text-2xl font-bold text-white">{t('stats.title')}</h1>
|
|
|
+ {isRefetching && <Loader2 className="w-5 h-5 text-bambu-green animate-spin" />}
|
|
|
+ </div>
|
|
|
<p className="text-bambu-gray">{t('stats.subtitle')}</p>
|
|
|
</div>
|
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
|
@@ -1180,6 +1207,63 @@ export function StatsPage() {
|
|
|
</div>
|
|
|
)}
|
|
|
</div>
|
|
|
+ {/* User Filter */}
|
|
|
+ {canFilterByUser && users && users.length > 0 && (
|
|
|
+ <div className="relative">
|
|
|
+ <Button
|
|
|
+ variant="secondary"
|
|
|
+ onClick={() => setShowUserPicker(!showUserPicker)}
|
|
|
+ >
|
|
|
+ <Users className="w-4 h-4" />
|
|
|
+ {selectedUserLabel}
|
|
|
+ <ChevronDown className="w-3 h-3" />
|
|
|
+ </Button>
|
|
|
+ {showUserPicker && (
|
|
|
+ <>
|
|
|
+ <div
|
|
|
+ className="fixed inset-0 z-10"
|
|
|
+ onClick={() => setShowUserPicker(false)}
|
|
|
+ />
|
|
|
+ <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 p-2 max-h-64 overflow-y-auto">
|
|
|
+ <button
|
|
|
+ className={`w-full px-3 py-2 text-left text-sm rounded-md transition-colors ${
|
|
|
+ selectedUserId === null
|
|
|
+ ? 'bg-bambu-green text-white'
|
|
|
+ : 'text-white hover:bg-bambu-dark-tertiary'
|
|
|
+ }`}
|
|
|
+ onClick={() => { setSelectedUserId(null); setShowUserPicker(false); }}
|
|
|
+ >
|
|
|
+ {t('stats.allUsers', 'All Users')}
|
|
|
+ </button>
|
|
|
+ <button
|
|
|
+ className={`w-full px-3 py-2 text-left text-sm rounded-md transition-colors ${
|
|
|
+ selectedUserId === -1
|
|
|
+ ? 'bg-bambu-green text-white'
|
|
|
+ : 'text-white hover:bg-bambu-dark-tertiary'
|
|
|
+ }`}
|
|
|
+ onClick={() => { setSelectedUserId(-1); setShowUserPicker(false); }}
|
|
|
+ >
|
|
|
+ {t('stats.noUser', 'No User (System)')}
|
|
|
+ </button>
|
|
|
+ <div className="border-t border-bambu-dark-tertiary my-1" />
|
|
|
+ {users.map(u => (
|
|
|
+ <button
|
|
|
+ key={u.id}
|
|
|
+ className={`w-full px-3 py-2 text-left text-sm rounded-md transition-colors ${
|
|
|
+ selectedUserId === u.id
|
|
|
+ ? 'bg-bambu-green text-white'
|
|
|
+ : 'text-white hover:bg-bambu-dark-tertiary'
|
|
|
+ }`}
|
|
|
+ onClick={() => { setSelectedUserId(u.id); setShowUserPicker(false); }}
|
|
|
+ >
|
|
|
+ {u.username}
|
|
|
+ </button>
|
|
|
+ ))}
|
|
|
+ </div>
|
|
|
+ </>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
{/* Timeframe Selector */}
|
|
|
<div className="relative">
|
|
|
<Button
|