Phase 3.1: Add Reports page - weekly dashboard with charts, leaderboards, stats

This commit is contained in:
2026-02-05 12:25:05 +11:00
parent e88f9d2986
commit ca702b9703

View 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;