Phase 3.1: Add Reports page - weekly dashboard with charts, leaderboards, stats
This commit is contained in:
400
frontend/src/pages/Reports.tsx
Normal file
400
frontend/src/pages/Reports.tsx
Normal file
@@ -0,0 +1,400 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
|
import { choreLogsService, WeeklyChoreReport } from '../api/choreLogs';
|
||||||
|
import {
|
||||||
|
ChartBarIcon,
|
||||||
|
TrophyIcon,
|
||||||
|
CalendarIcon,
|
||||||
|
UserGroupIcon,
|
||||||
|
ArrowLeftIcon,
|
||||||
|
ChevronLeftIcon,
|
||||||
|
ChevronRightIcon
|
||||||
|
} from '@heroicons/react/24/outline';
|
||||||
|
|
||||||
|
const Reports: React.FC = () => {
|
||||||
|
const { user, logout } = useAuth();
|
||||||
|
const [report, setReport] = useState<WeeklyChoreReport | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [weeksAgo, setWeeksAgo] = useState(0);
|
||||||
|
const [selectedUserId, setSelectedUserId] = useState<number | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadReport();
|
||||||
|
}, [weeksAgo, selectedUserId]);
|
||||||
|
|
||||||
|
const loadReport = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const data = await choreLogsService.getWeeklyReport(
|
||||||
|
selectedUserId || undefined,
|
||||||
|
weeksAgo
|
||||||
|
);
|
||||||
|
setReport(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load report:', error);
|
||||||
|
setError('Failed to load report. Please try again.');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateString: string) => {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
return date.toLocaleDateString('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const getWeekLabel = () => {
|
||||||
|
if (weeksAgo === 0) return 'This Week';
|
||||||
|
if (weeksAgo === 1) return 'Last Week';
|
||||||
|
return `${weeksAgo} Weeks Ago`;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="inline-block animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
|
||||||
|
<p className="mt-4 text-gray-600">Loading report...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
|
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
||||||
|
<p className="text-red-800">{error}</p>
|
||||||
|
<button
|
||||||
|
onClick={loadReport}
|
||||||
|
className="mt-2 text-red-600 hover:text-red-800 font-medium"
|
||||||
|
>
|
||||||
|
Try Again
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="bg-white shadow">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="flex justify-between items-center py-6">
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<Link
|
||||||
|
to="/dashboard"
|
||||||
|
className="text-gray-600 hover:text-gray-900"
|
||||||
|
>
|
||||||
|
<ArrowLeftIcon className="h-6 w-6" />
|
||||||
|
</Link>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900">
|
||||||
|
Weekly Reports
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 text-sm text-gray-500">
|
||||||
|
Family chore completion statistics
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<span className="text-sm text-gray-600">
|
||||||
|
{user?.full_name || user?.username}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={logout}
|
||||||
|
className="text-sm text-gray-600 hover:text-gray-900"
|
||||||
|
>
|
||||||
|
Logout
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
|
{/* Week Navigation */}
|
||||||
|
<div className="bg-white rounded-lg shadow p-6 mb-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<button
|
||||||
|
onClick={() => setWeeksAgo(weeksAgo + 1)}
|
||||||
|
className="flex items-center space-x-2 px-4 py-2 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<ChevronLeftIcon className="h-5 w-5" />
|
||||||
|
<span>Previous Week</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="flex items-center space-x-2 text-gray-900">
|
||||||
|
<CalendarIcon className="h-5 w-5" />
|
||||||
|
<span className="text-lg font-semibold">{getWeekLabel()}</span>
|
||||||
|
</div>
|
||||||
|
{report && (
|
||||||
|
<p className="text-sm text-gray-500 mt-1">
|
||||||
|
{formatDate(report.start_date)} - {formatDate(report.end_date)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setWeeksAgo(Math.max(0, weeksAgo - 1))}
|
||||||
|
disabled={weeksAgo === 0}
|
||||||
|
className={`flex items-center space-x-2 px-4 py-2 rounded-lg transition-colors ${
|
||||||
|
weeksAgo === 0
|
||||||
|
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
|
||||||
|
: 'bg-gray-100 hover:bg-gray-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span>Next Week</span>
|
||||||
|
<ChevronRightIcon className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{report && (
|
||||||
|
<>
|
||||||
|
{/* Stats Cards */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-6">
|
||||||
|
{/* Total Completions */}
|
||||||
|
<div className="bg-white rounded-lg shadow p-6">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<ChartBarIcon className="h-8 w-8 text-blue-600" />
|
||||||
|
</div>
|
||||||
|
<div className="ml-4">
|
||||||
|
<p className="text-sm font-medium text-gray-500">
|
||||||
|
Total Completions
|
||||||
|
</p>
|
||||||
|
<p className="text-3xl font-bold text-gray-900">
|
||||||
|
{report.total_completions}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Active Users */}
|
||||||
|
<div className="bg-white rounded-lg shadow p-6">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<UserGroupIcon className="h-8 w-8 text-green-600" />
|
||||||
|
</div>
|
||||||
|
<div className="ml-4">
|
||||||
|
<p className="text-sm font-medium text-gray-500">
|
||||||
|
Active Members
|
||||||
|
</p>
|
||||||
|
<p className="text-3xl font-bold text-gray-900">
|
||||||
|
{Object.keys(report.completions_by_user).length}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Different Chores */}
|
||||||
|
<div className="bg-white rounded-lg shadow p-6">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<TrophyIcon className="h-8 w-8 text-yellow-600" />
|
||||||
|
</div>
|
||||||
|
<div className="ml-4">
|
||||||
|
<p className="text-sm font-medium text-gray-500">
|
||||||
|
Different Chores
|
||||||
|
</p>
|
||||||
|
<p className="text-3xl font-bold text-gray-900">
|
||||||
|
{Object.keys(report.completions_by_chore).length}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Top Performers */}
|
||||||
|
<div className="bg-white rounded-lg shadow p-6 mb-6">
|
||||||
|
<h2 className="text-xl font-bold text-gray-900 mb-4 flex items-center">
|
||||||
|
<TrophyIcon className="h-6 w-6 mr-2 text-yellow-600" />
|
||||||
|
Top Performers
|
||||||
|
</h2>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{report.top_performers.map((performer, index) => (
|
||||||
|
<div
|
||||||
|
key={performer.username}
|
||||||
|
className="flex items-center justify-between p-4 bg-gray-50 rounded-lg"
|
||||||
|
>
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<div className={`flex-shrink-0 w-10 h-10 rounded-full flex items-center justify-center font-bold text-white ${
|
||||||
|
index === 0 ? 'bg-yellow-500' :
|
||||||
|
index === 1 ? 'bg-gray-400' :
|
||||||
|
index === 2 ? 'bg-orange-600' :
|
||||||
|
'bg-blue-500'
|
||||||
|
}`}>
|
||||||
|
{index + 1}
|
||||||
|
</div>
|
||||||
|
{performer.avatar_url ? (
|
||||||
|
<img
|
||||||
|
src={`http://10.0.0.243:8000${performer.avatar_url}`}
|
||||||
|
alt={performer.username}
|
||||||
|
className="w-12 h-12 rounded-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-12 h-12 rounded-full bg-gradient-to-br from-blue-400 to-purple-500 flex items-center justify-center text-white font-bold text-lg">
|
||||||
|
{performer.username.charAt(0).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-gray-900">
|
||||||
|
{performer.username}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
{performer.count} {performer.count === 1 ? 'chore' : 'chores'} completed
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="text-2xl font-bold text-gray-900">
|
||||||
|
{performer.count}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{report.top_performers.length === 0 && (
|
||||||
|
<p className="text-gray-500 text-center py-8">
|
||||||
|
No completions recorded this week
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Completions by Day */}
|
||||||
|
<div className="bg-white rounded-lg shadow p-6 mb-6">
|
||||||
|
<h2 className="text-xl font-bold text-gray-900 mb-4">
|
||||||
|
Completions by Day
|
||||||
|
</h2>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'].map((day) => {
|
||||||
|
const count = report.completions_by_day[day] || 0;
|
||||||
|
const maxCount = Math.max(...Object.values(report.completions_by_day));
|
||||||
|
const percentage = maxCount > 0 ? (count / maxCount) * 100 : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={day} className="flex items-center space-x-4">
|
||||||
|
<div className="w-24 text-sm font-medium text-gray-700">
|
||||||
|
{day}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="w-full bg-gray-200 rounded-full h-8">
|
||||||
|
<div
|
||||||
|
className="bg-blue-600 h-8 rounded-full flex items-center justify-end pr-3 transition-all"
|
||||||
|
style={{ width: `${percentage}%` }}
|
||||||
|
>
|
||||||
|
{count > 0 && (
|
||||||
|
<span className="text-white text-sm font-semibold">
|
||||||
|
{count}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Completions by Chore */}
|
||||||
|
<div className="bg-white rounded-lg shadow p-6 mb-6">
|
||||||
|
<h2 className="text-xl font-bold text-gray-900 mb-4">
|
||||||
|
Completions by Chore
|
||||||
|
</h2>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{Object.entries(report.completions_by_chore)
|
||||||
|
.sort(([, a], [, b]) => b - a)
|
||||||
|
.map(([chore, count]) => (
|
||||||
|
<div key={chore} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
||||||
|
<span className="text-gray-900 font-medium">{chore}</span>
|
||||||
|
<span className="text-blue-600 font-bold">{count}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{Object.keys(report.completions_by_chore).length === 0 && (
|
||||||
|
<p className="text-gray-500 text-center py-8 col-span-2">
|
||||||
|
No chores completed this week
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recent Completions */}
|
||||||
|
<div className="bg-white rounded-lg shadow p-6">
|
||||||
|
<h2 className="text-xl font-bold text-gray-900 mb-4">
|
||||||
|
Recent Completions
|
||||||
|
</h2>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{report.recent_completions.map((completion) => (
|
||||||
|
<div
|
||||||
|
key={completion.id}
|
||||||
|
className="flex items-center justify-between p-4 bg-gray-50 rounded-lg"
|
||||||
|
>
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
{completion.user_avatar ? (
|
||||||
|
<img
|
||||||
|
src={`http://10.0.0.243:8000${completion.user_avatar}`}
|
||||||
|
alt={completion.user_name || 'User'}
|
||||||
|
className="w-10 h-10 rounded-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-blue-400 to-purple-500 flex items-center justify-center text-white font-bold">
|
||||||
|
{completion.user_name?.charAt(0).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-gray-900">
|
||||||
|
{completion.chore_title}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
by {completion.user_name}
|
||||||
|
{completion.notes && (
|
||||||
|
<span className="ml-2 italic">- "{completion.notes}"</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
{new Date(completion.completed_at).toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-400">
|
||||||
|
{new Date(completion.completed_at).toLocaleTimeString([], {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{report.recent_completions.length === 0 && (
|
||||||
|
<p className="text-gray-500 text-center py-8">
|
||||||
|
No completions to show
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Reports;
|
||||||
Reference in New Issue
Block a user