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