Phase 3.1: Enhanced Chore Logging and Reporting System

This commit is contained in:
2026-02-05 12:33:51 +11:00
commit e3cae7bfbb
178 changed files with 30105 additions and 0 deletions

View File

@@ -0,0 +1,7 @@
import { describe, it, expect } from 'vitest'
describe('App', () => {
it('renders without crashing', () => {
expect(true).toBe(true)
})
})

120
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,120 @@
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
import { AuthProvider, useAuth } from './contexts/AuthContext';
import Login from './pages/Login';
import Dashboard from './pages/Dashboard';
import Settings from './pages/Settings';
import KioskView from './pages/KioskView';
import Reports from './pages/Reports';
import UserStatsPage from './pages/UserStatsPage';
// Protected route wrapper
function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { user, isLoading } = useAuth();
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<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...</p>
</div>
</div>
);
}
if (!user) {
return <Navigate to="/login" replace />;
}
return <>{children}</>;
}
// Public route wrapper (redirects to dashboard if already logged in)
function PublicRoute({ children }: { children: React.ReactNode }) {
const { user, isLoading } = useAuth();
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<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...</p>
</div>
</div>
);
}
if (user) {
return <Navigate to="/dashboard" replace />;
}
return <>{children}</>;
}
function App() {
return (
<Router>
<AuthProvider>
<Routes>
{/* Public Kiosk View - No Auth Required */}
<Route path="/kiosk" element={<KioskView />} />
{/* Public routes */}
<Route
path="/login"
element={
<PublicRoute>
<Login />
</PublicRoute>
}
/>
{/* Protected routes */}
<Route
path="/dashboard"
element={
<ProtectedRoute>
<Dashboard />
</ProtectedRoute>
}
/>
<Route
path="/settings"
element={
<ProtectedRoute>
<Settings />
</ProtectedRoute>
}
/>
<Route
path="/reports"
element={
<ProtectedRoute>
<Reports />
</ProtectedRoute>
}
/>
<Route
path="/stats"
element={
<ProtectedRoute>
<UserStatsPage />
</ProtectedRoute>
}
/>
{/* Default route */}
<Route path="/" element={<Navigate to="/dashboard" replace />} />
{/* 404 catch-all */}
<Route path="*" element={<Navigate to="/dashboard" replace />} />
</Routes>
</AuthProvider>
</Router>
);
}
export default App;

46
frontend/src/api/auth.ts Normal file
View File

@@ -0,0 +1,46 @@
import api from './axios';
export interface LoginRequest {
username: string;
password: string;
}
export interface LoginResponse {
access_token: string;
token_type: string;
}
export interface User {
id: number;
username: string;
email: string;
full_name: string;
is_admin: boolean;
is_active: boolean;
}
export const authService = {
async login(credentials: LoginRequest): Promise<LoginResponse> {
const formData = new URLSearchParams();
formData.append('username', credentials.username);
formData.append('password', credentials.password);
const response = await api.post<LoginResponse>('/api/v1/auth/login', formData, {
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
});
return response.data;
},
async getCurrentUser(): Promise<User> {
const response = await api.get<User>('/api/v1/auth/me');
return response.data;
},
logout() {
localStorage.removeItem('token');
localStorage.removeItem('user');
window.location.href = '/login';
},
};

34
frontend/src/api/axios.ts Normal file
View File

@@ -0,0 +1,34 @@
import axios from 'axios';
export const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8001';
const api = axios.create({
baseURL: API_BASE_URL,
headers: {
'Content-Type': 'application/json',
},
});
// Add token to requests if it exists
api.interceptors.request.use((config) => {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// Handle 401 responses
api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
localStorage.removeItem('token');
localStorage.removeItem('user');
window.location.href = '/login';
}
return Promise.reject(error);
}
);
export default api;

View File

@@ -0,0 +1,125 @@
/**
* API service for chore completion logs and reporting
*/
import api from './axios';
// Interfaces
export interface ChoreCompletionLog {
id: number;
chore_id: number;
user_id: number;
completed_at: string;
notes?: string;
verified_by_user_id?: number;
created_at: string;
chore_title?: string;
user_name?: string;
user_avatar?: string;
verified_by_name?: string;
}
export interface WeeklyChoreReport {
start_date: string;
end_date: string;
total_completions: number;
completions_by_user: Record<string, number>;
completions_by_chore: Record<string, number>;
completions_by_day: Record<string, number>;
top_performers: Array<{
username: string;
count: number;
avatar_url?: string;
}>;
recent_completions: ChoreCompletionLog[];
}
export interface UserChoreStats {
user_id: number;
username: string;
full_name?: string;
avatar_url?: string;
total_completions: number;
completions_this_week: number;
completions_this_month: number;
favorite_chore?: string;
recent_completions: ChoreCompletionLog[];
}
export interface CompleteChoreRequest {
notes?: string;
}
export interface CompletionLogsParams {
skip?: number;
limit?: number;
chore_id?: number;
user_id?: number;
start_date?: string;
end_date?: string;
}
// Service
export const choreLogsService = {
/**
* Complete a chore and log it
*/
async completeChore(choreId: number, notes?: string): Promise<ChoreCompletionLog> {
const response = await api.post<ChoreCompletionLog>(
`/api/v1/chores/${choreId}/complete`,
{ notes }
);
return response.data;
},
/**
* Get completion logs with optional filters
*/
async getCompletionLogs(params?: CompletionLogsParams): Promise<ChoreCompletionLog[]> {
const response = await api.get<ChoreCompletionLog[]>('/api/v1/chores/completions', {
params,
});
return response.data;
},
/**
* Get weekly report
*/
async getWeeklyReport(userId?: number, weeksAgo: number = 0): Promise<WeeklyChoreReport> {
const response = await api.get<WeeklyChoreReport>('/api/v1/chores/reports/weekly', {
params: {
user_id: userId,
weeks_ago: weeksAgo,
},
});
return response.data;
},
/**
* Get user statistics
*/
async getUserStats(userId: number): Promise<UserChoreStats> {
const response = await api.get<UserChoreStats>(
`/api/v1/chores/reports/user/${userId}`
);
return response.data;
},
/**
* Verify a completion
*/
async verifyCompletion(logId: number): Promise<ChoreCompletionLog> {
const response = await api.post<ChoreCompletionLog>(
`/api/v1/chores/completions/${logId}/verify`
);
return response.data;
},
/**
* Delete a completion log
*/
async deleteCompletionLog(logId: number): Promise<void> {
await api.delete(`/api/v1/chores/completions/${logId}`);
},
};
export default choreLogsService;

View File

@@ -0,0 +1,94 @@
import api from './axios';
export interface AssignedUser {
id: number;
username: string;
full_name: string;
avatar_url?: string;
birthday?: string;
completed_at?: string;
}
export interface Chore {
id: number;
title: string;
description?: string;
room: string;
frequency: 'daily' | 'weekly' | 'fortnightly' | 'monthly' | 'on_trigger';
points: number;
image_url?: string;
assignment_type: 'any_one' | 'all_assigned';
status: 'pending' | 'in_progress' | 'completed' | 'skipped';
assigned_users: AssignedUser[]; // Multiple users
assigned_user_id?: number; // Legacy field
assigned_user?: { // Legacy field
id: number;
username: string;
full_name: string;
};
due_date?: string;
completed_at?: string;
created_at: string;
updated_at: string;
}
export interface CreateChoreRequest {
title: string;
description?: string;
room: string;
frequency: 'daily' | 'weekly' | 'fortnightly' | 'monthly' | 'on_trigger';
points?: number;
assignment_type?: 'any_one' | 'all_assigned';
assigned_user_ids?: number[]; // Multiple users
due_date?: string;
}
export interface UpdateChoreRequest {
title?: string;
description?: string;
room?: string;
frequency?: 'daily' | 'weekly' | 'fortnightly' | 'monthly' | 'on_trigger';
points?: number;
assignment_type?: 'any_one' | 'all_assigned';
status?: 'pending' | 'in_progress' | 'completed' | 'skipped';
assigned_user_ids?: number[]; // Multiple users
due_date?: string;
}
export const choreService = {
async getChores(params?: { user_id?: number; exclude_birthdays?: boolean }): Promise<Chore[]> {
const response = await api.get<Chore[]>('/api/v1/chores', { params });
return response.data;
},
async getChore(id: number): Promise<Chore> {
const response = await api.get<Chore>(`/api/v1/chores/${id}`);
return response.data;
},
async createChore(chore: CreateChoreRequest): Promise<Chore> {
const response = await api.post<Chore>('/api/v1/chores', chore);
return response.data;
},
async updateChore(id: number, chore: UpdateChoreRequest): Promise<Chore> {
const response = await api.put<Chore>(`/api/v1/chores/${id}`, chore);
return response.data;
},
async deleteChore(id: number): Promise<void> {
await api.delete(`/api/v1/chores/${id}`);
},
async completeChore(id: number): Promise<Chore> {
const response = await api.put<Chore>(`/api/v1/chores/${id}`, {
status: 'completed',
});
return response.data;
},
async assignUsers(choreId: number, userIds: number[]): Promise<Chore> {
const response = await api.post<Chore>(`/api/v1/chores/${choreId}/assign`, userIds);
return response.data;
},
};

View File

@@ -0,0 +1,89 @@
import api from './axios';
export interface AssignedUser {
id: number;
username: string;
full_name: string;
birthday?: string;
completed_at?: string;
}
export interface Chore {
id: number;
title: string;
description?: string;
room: string;
frequency: 'daily' | 'weekly' | 'fortnightly' | 'monthly' | 'on_trigger';
points: number;
status: 'pending' | 'in_progress' | 'completed' | 'skipped';
assigned_users: AssignedUser[]; // Multiple users
assigned_user_id?: number; // Legacy field
assigned_user?: { // Legacy field
id: number;
username: string;
full_name: string;
};
due_date?: string;
completed_at?: string;
created_at: string;
updated_at: string;
}
export interface CreateChoreRequest {
title: string;
description?: string;
room: string;
frequency: 'daily' | 'weekly' | 'fortnightly' | 'monthly' | 'on_trigger';
points?: number;
assigned_user_ids?: number[]; // Multiple users
due_date?: string;
}
export interface UpdateChoreRequest {
title?: string;
description?: string;
room?: string;
frequency?: 'daily' | 'weekly' | 'fortnightly' | 'monthly' | 'on_trigger';
points?: number;
status?: 'pending' | 'in_progress' | 'completed' | 'skipped';
assigned_user_ids?: number[]; // Multiple users
due_date?: string;
}
export const choreService = {
async getChores(params?: { user_id?: number; exclude_birthdays?: boolean }): Promise<Chore[]> {
const response = await api.get<Chore[]>('/api/v1/chores', { params });
return response.data;
},
async getChore(id: number): Promise<Chore> {
const response = await api.get<Chore>(`/api/v1/chores/${id}`);
return response.data;
},
async createChore(chore: CreateChoreRequest): Promise<Chore> {
const response = await api.post<Chore>('/api/v1/chores', chore);
return response.data;
},
async updateChore(id: number, chore: UpdateChoreRequest): Promise<Chore> {
const response = await api.put<Chore>(`/api/v1/chores/${id}`, chore);
return response.data;
},
async deleteChore(id: number): Promise<void> {
await api.delete(`/api/v1/chores/${id}`);
},
async completeChore(id: number): Promise<Chore> {
const response = await api.put<Chore>(`/api/v1/chores/${id}`, {
status: 'completed',
});
return response.data;
},
async assignUsers(choreId: number, userIds: number[]): Promise<Chore> {
const response = await api.post<Chore>(`/api/v1/chores/${choreId}/assign`, userIds);
return response.data;
},
};

View File

@@ -0,0 +1,72 @@
import api from './axios';
export const uploadService = {
/**
* Upload user avatar
*/
async uploadAvatar(file: File): Promise<{ message: string; avatar_url: string }> {
const formData = new FormData();
formData.append('file', file);
const response = await api.post('/api/v1/uploads/users/avatar', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
return response.data;
},
/**
* Delete user avatar
*/
async deleteAvatar(): Promise<void> {
await api.delete('/api/v1/uploads/users/avatar');
},
/**
* Upload chore image
*/
async uploadChoreImage(choreId: number, file: File): Promise<{ message: string; image_url: string }> {
const formData = new FormData();
formData.append('file', file);
const response = await api.post(`/api/v1/uploads/chores/${choreId}/image`, formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
return response.data;
},
/**
* Delete chore image
*/
async deleteChoreImage(choreId: number): Promise<void> {
await api.delete(`/api/v1/uploads/chores/${choreId}/image`);
},
/**
* Upload avatar for another user (admin only)
*/
async uploadAvatarForUser(userId: number, file: File): Promise<{ message: string; avatar_url: string }> {
const formData = new FormData();
formData.append('file', file);
const response = await api.post(`/api/v1/uploads/admin/users/${userId}/avatar`, formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
return response.data;
},
/**
* Delete avatar for another user (admin only)
*/
async deleteAvatarForUser(userId: number): Promise<void> {
await api.delete(`/api/v1/uploads/admin/users/${userId}/avatar`);
},
};

View File

@@ -0,0 +1,168 @@
import React, { useState, useRef } from 'react';
import { uploadService } from '../api/uploads';
import { API_BASE_URL } from '../api/axios';
interface AvatarUploadProps {
currentAvatarUrl?: string;
onUploadSuccess: (avatarUrl: string) => void;
onDeleteSuccess?: () => void;
userId?: number; // For admin uploading for other users
}
const AvatarUpload: React.FC<AvatarUploadProps> = ({
currentAvatarUrl,
onUploadSuccess,
onDeleteSuccess,
userId
}) => {
const [isUploading, setIsUploading] = useState(false);
const [error, setError] = useState('');
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
// Validate file type
const validTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp'];
if (!validTypes.includes(file.type)) {
setError('Please select a valid image file (JPG, PNG, GIF, or WEBP)');
return;
}
// Validate file size (5MB)
if (file.size > 5 * 1024 * 1024) {
setError('File size must be less than 5MB');
return;
}
setError('');
setIsUploading(true);
// Create preview
const reader = new FileReader();
reader.onloadend = () => {
setPreviewUrl(reader.result as string);
};
reader.readAsDataURL(file);
try {
const result = userId
? await uploadService.uploadAvatarForUser(userId, file)
: await uploadService.uploadAvatar(file);
onUploadSuccess(result.avatar_url);
} catch (err: any) {
setError(err.response?.data?.detail || 'Failed to upload avatar');
setPreviewUrl(null);
} finally {
setIsUploading(false);
}
};
const handleDelete = async () => {
if (!window.confirm('Are you sure you want to delete your avatar?')) {
return;
}
setIsUploading(true);
setError('');
try {
if (userId) {
await uploadService.deleteAvatarForUser(userId);
} else {
await uploadService.deleteAvatar();
}
setPreviewUrl(null);
if (onDeleteSuccess) {
onDeleteSuccess();
}
} catch (err: any) {
setError(err.response?.data?.detail || 'Failed to delete avatar');
} finally {
setIsUploading(false);
}
};
const displayUrl = previewUrl || (currentAvatarUrl ? `${API_BASE_URL}${currentAvatarUrl}` : null);
return (
<div className="space-y-4">
<div className="flex items-center gap-4">
{/* Avatar Preview */}
<div className="relative">
{displayUrl ? (
<img
src={displayUrl}
alt="Avatar"
className="w-24 h-24 rounded-full object-cover border-2 border-gray-200"
/>
) : (
<div className="w-24 h-24 rounded-full bg-gray-200 flex items-center justify-center border-2 border-gray-300">
<svg className="w-12 h-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
</div>
)}
{isUploading && (
<div className="absolute inset-0 bg-black bg-opacity-50 rounded-full flex items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-white"></div>
</div>
)}
</div>
{/* Upload/Delete Buttons */}
<div className="flex flex-col gap-2">
<input
ref={fileInputRef}
type="file"
accept="image/jpeg,image/jpg,image/png,image/gif,image/webp"
onChange={handleFileSelect}
className="hidden"
/>
<button
onClick={() => fileInputRef.current?.click()}
disabled={isUploading}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
{currentAvatarUrl ? 'Change Avatar' : 'Upload Avatar'}
</button>
{currentAvatarUrl && (
<button
onClick={handleDelete}
disabled={isUploading}
className="px-4 py-2 bg-red-100 text-red-700 rounded-lg hover:bg-red-200 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
Delete
</button>
)}
</div>
</div>
{/* File Info */}
<div className="text-sm text-gray-600">
<p>Accepted formats: JPG, PNG, GIF, WEBP</p>
<p>Maximum file size: 5MB</p>
</div>
{/* Error Message */}
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
{error}
</div>
)}
</div>
);
};
export default AvatarUpload;

View File

@@ -0,0 +1,193 @@
import React from 'react';
import { Chore } from '../api/chores';
import { useAuth } from '../contexts/AuthContext';
import { getUserColor, getInitials } from '../utils/avatarUtils';
import { API_BASE_URL } from '../api/axios';
interface ChoreCardProps {
chore: Chore;
onComplete: (id: number) => void;
onDelete: (id: number) => void;
onEdit?: (id: number) => void;
}
const ChoreCard: React.FC<ChoreCardProps> = ({ chore, onComplete, onDelete, onEdit }) => {
const { user } = useAuth();
const statusColors = {
pending: 'bg-yellow-100 text-yellow-800',
in_progress: 'bg-blue-100 text-blue-800',
completed: 'bg-green-100 text-green-800',
skipped: 'bg-gray-100 text-gray-800',
};
const frequencyIcons = {
daily: '📅',
weekly: '📆',
fortnightly: '🗓️',
monthly: '📊',
on_trigger: '⏱️',
};
// Check if current user is assigned to this chore
const isAssignedToMe = chore.assigned_users?.some(u => u.id === user?.id);
const myAssignment = chore.assigned_users?.find(u => u.id === user?.id);
const myCompletionStatus = myAssignment?.completed_at ? 'completed' : chore.status;
// Check if today is anyone's birthday
const today = new Date();
const hasBirthdayUser = chore.assigned_users?.some(u => {
if (!u.birthday) return false;
const bday = new Date(u.birthday);
return bday.getMonth() === today.getMonth() && bday.getDate() === today.getDate();
});
return (
<div className="bg-white rounded-lg shadow-md p-4 hover:shadow-lg transition-shadow">
<div className="flex justify-between items-start mb-3">
<div className="flex-1">
<h3 className="text-lg font-semibold text-gray-900">{chore.title}</h3>
{chore.points > 0 && (
<div className="flex items-center mt-1">
<span className="text-sm font-medium text-amber-600"> {chore.points} pts</span>
</div>
)}
</div>
<span className={`px-2 py-1 rounded-full text-xs font-medium ${statusColors[chore.status]}`}>
{chore.status.replace('_', ' ')}
</span>
</div>
{chore.description && (
<p className="text-sm text-gray-600 mb-3">{chore.description}</p>
)}
{/* Chore Image */}
{chore.image_url && (
<div className="mb-3">
<img
src={`${API_BASE_URL}${chore.image_url}`}
alt={chore.title}
className="w-full h-48 object-cover rounded-lg border border-gray-200"
/>
</div>
)}
<div className="space-y-2 mb-4">
<div className="flex items-center text-sm text-gray-500">
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
</svg>
<span className="font-medium">{chore.room}</span>
</div>
<div className="flex items-center text-sm text-gray-500">
<span className="mr-2">{frequencyIcons[chore.frequency] || '📋'}</span>
<span className="capitalize">{chore.frequency.replace('_', ' ')}</span>
</div>
{/* Assigned Users */}
{chore.assigned_users && chore.assigned_users.length > 0 && (
<div className="space-y-1">
<div className="flex items-center text-sm font-medium text-gray-700">
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
Assigned to:
</div>
<div className="pl-6 space-y-1">
{chore.assigned_users.map(assignedUser => {
const isBirthday = assignedUser.birthday && (() => {
const bday = new Date(assignedUser.birthday);
return bday.getMonth() === today.getMonth() && bday.getDate() === today.getDate();
})();
return (
<div key={assignedUser.id} className="flex items-center justify-between text-sm">
<div className="flex items-center gap-2">
{/* User Avatar */}
{assignedUser.avatar_url ? (
<img
src={`${API_BASE_URL}${assignedUser.avatar_url}`}
alt={assignedUser.full_name}
className="w-6 h-6 rounded-full object-cover border border-gray-200"
/>
) : (
<div className={`w-6 h-6 rounded-full ${getUserColor(assignedUser.full_name)} flex items-center justify-center text-xs font-semibold text-white`}>
{getInitials(assignedUser.full_name)}
</div>
)}
<span className={`${assignedUser.id === user?.id ? 'font-medium text-blue-600' : 'text-gray-600'}`}>
{assignedUser.full_name}
{isBirthday && ' 🎂'}
</span>
</div>
{assignedUser.completed_at && (
<span className="text-xs text-green-600"> Done</span>
)}
</div>
);
})}
</div>
</div>
)}
{chore.due_date && (
<div className="flex items-center text-sm text-gray-500">
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<span>{new Date(chore.due_date).toLocaleDateString()}</span>
</div>
)}
</div>
<div className="flex gap-2">
{isAssignedToMe && myCompletionStatus !== 'completed' && (
<button
onClick={() => onComplete(chore.id)}
className="flex-1 px-3 py-2 bg-green-600 text-white text-sm rounded-lg hover:bg-green-700 transition-colors flex items-center justify-center gap-1"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
Complete
</button>
)}
{user?.is_admin && onEdit && (
<button
onClick={() => onEdit(chore.id)}
className="px-3 py-2 bg-blue-100 text-blue-700 text-sm rounded-lg hover:bg-blue-200 transition-colors flex items-center gap-1"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
Edit
</button>
)}
{user?.is_admin && (
<button
onClick={() => onDelete(chore.id)}
className="px-3 py-2 bg-red-100 text-red-700 text-sm rounded-lg hover:bg-red-200 transition-colors"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
)}
</div>
{hasBirthdayUser && (
<div className="mt-3 pt-3 border-t border-gray-200">
<p className="text-xs text-purple-600 flex items-center gap-1">
<span>🎂</span>
<span>Birthday chore! Give them a break today.</span>
</p>
</div>
)}
</div>
);
};
export default ChoreCard;

View File

@@ -0,0 +1,154 @@
import React, { useState, useRef } from 'react';
import { uploadService } from '../api/uploads';
import { API_BASE_URL } from '../api/axios';
interface ChoreImageUploadProps {
choreId: number;
currentImageUrl?: string;
onUploadSuccess: (imageUrl: string) => void;
onDeleteSuccess?: () => void;
}
const ChoreImageUpload: React.FC<ChoreImageUploadProps> = ({
choreId,
currentImageUrl,
onUploadSuccess,
onDeleteSuccess
}) => {
const [isUploading, setIsUploading] = useState(false);
const [error, setError] = useState('');
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
// Validate file type
const validTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp'];
if (!validTypes.includes(file.type)) {
setError('Please select a valid image file (JPG, PNG, GIF, or WEBP)');
return;
}
// Validate file size (5MB)
if (file.size > 5 * 1024 * 1024) {
setError('File size must be less than 5MB');
return;
}
setError('');
setIsUploading(true);
// Create preview
const reader = new FileReader();
reader.onloadend = () => {
setPreviewUrl(reader.result as string);
};
reader.readAsDataURL(file);
try {
const result = await uploadService.uploadChoreImage(choreId, file);
onUploadSuccess(result.image_url);
} catch (err: any) {
setError(err.response?.data?.detail || 'Failed to upload image');
setPreviewUrl(null);
} finally {
setIsUploading(false);
}
};
const handleDelete = async () => {
if (!window.confirm('Are you sure you want to delete this image?')) {
return;
}
setIsUploading(true);
setError('');
try {
await uploadService.deleteChoreImage(choreId);
setPreviewUrl(null);
if (onDeleteSuccess) {
onDeleteSuccess();
}
} catch (err: any) {
setError(err.response?.data?.detail || 'Failed to delete image');
} finally {
setIsUploading(false);
}
};
const displayUrl = previewUrl || (currentImageUrl ? `${API_BASE_URL}${currentImageUrl}` : null);
return (
<div className="space-y-4">
{/* Image Preview */}
{displayUrl && (
<div className="relative">
<img
src={displayUrl}
alt="Chore"
className="w-full max-h-64 object-cover rounded-lg border-2 border-gray-200"
/>
{isUploading && (
<div className="absolute inset-0 bg-black bg-opacity-50 rounded-lg flex items-center justify-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-white"></div>
</div>
)}
</div>
)}
{/* Upload/Delete Buttons */}
<div className="flex gap-2">
<input
ref={fileInputRef}
type="file"
accept="image/jpeg,image/jpg,image/png,image/gif,image/webp"
onChange={handleFileSelect}
className="hidden"
/>
<button
onClick={() => fileInputRef.current?.click()}
disabled={isUploading}
className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
{currentImageUrl ? 'Change Image' : 'Upload Image'}
</button>
{currentImageUrl && (
<button
onClick={handleDelete}
disabled={isUploading}
className="px-4 py-2 bg-red-100 text-red-700 rounded-lg hover:bg-red-200 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
Delete
</button>
)}
</div>
{/* File Info */}
<div className="text-sm text-gray-600">
<p>📸 Add a reference photo for this chore</p>
<p className="text-xs">Accepted: JPG, PNG, GIF, WEBP Max: 5MB</p>
</div>
{/* Error Message */}
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
{error}
</div>
)}
</div>
);
};
export default ChoreImageUpload;

View File

@@ -0,0 +1,297 @@
import React, { useState, useEffect } from 'react';
import { choreService, CreateChoreRequest } from '../api/chores';
import api from '../api/axios';
interface User {
id: number;
username: string;
full_name: string;
is_active: boolean;
}
interface CreateChoreModalProps {
onClose: () => void;
onSuccess: () => void;
}
const CreateChoreModal: React.FC<CreateChoreModalProps> = ({ onClose, onSuccess }) => {
const [formData, setFormData] = useState<CreateChoreRequest>({
title: '',
description: '',
room: '',
frequency: 'daily',
points: 5,
assigned_user_ids: [],
due_date: '',
});
const [users, setUsers] = useState<User[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
loadUsers();
}, []);
const loadUsers = async () => {
try {
const response = await api.get<User[]>('/api/v1/users');
setUsers(response.data.filter(u => u.is_active));
} catch (error) {
console.error('Failed to load users:', error);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setIsLoading(true);
try {
const submitData = { ...formData };
if (submitData.due_date) {
submitData.due_date = `${submitData.due_date}T23:59:59`;
}
await choreService.createChore(submitData);
onSuccess();
} catch (err: any) {
let errorMessage = 'Failed to create chore';
if (err.response?.data) {
const errorData = err.response.data;
if (Array.isArray(errorData.detail)) {
errorMessage = errorData.detail
.map((e: any) => `${e.loc?.join('.')}: ${e.msg}`)
.join(', ');
} else if (typeof errorData.detail === 'string') {
errorMessage = errorData.detail;
} else if (typeof errorData === 'string') {
errorMessage = errorData;
}
}
setError(errorMessage);
} finally {
setIsLoading(false);
}
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
const { name, value } = e.target;
setFormData(prev => ({
...prev,
[name]: name === 'points' ? parseInt(value) || 0 : value,
}));
};
const toggleUserAssignment = (userId: number) => {
setFormData(prev => {
const currentIds = prev.assigned_user_ids || [];
const isAssigned = currentIds.includes(userId);
return {
...prev,
assigned_user_ids: isAssigned
? currentIds.filter(id => id !== userId)
: [...currentIds, userId]
};
});
};
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
<div className="bg-white rounded-lg shadow-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
<div className="p-6">
<div className="flex justify-between items-center mb-6">
<h2 className="text-2xl font-bold text-gray-900">Create New Chore</h2>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 transition-colors"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
{error}
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="md:col-span-2">
<label htmlFor="title" className="block text-sm font-medium text-gray-700 mb-2">
Chore Title *
</label>
<input
id="title"
name="title"
type="text"
value={formData.title}
onChange={handleChange}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent text-gray-900"
placeholder="e.g., Vacuum living room"
required
/>
</div>
<div className="md:col-span-2">
<label htmlFor="description" className="block text-sm font-medium text-gray-700 mb-2">
Description
</label>
<textarea
id="description"
name="description"
value={formData.description}
onChange={handleChange}
rows={2}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent text-gray-900"
placeholder="Additional details..."
/>
</div>
<div>
<label htmlFor="room" className="block text-sm font-medium text-gray-700 mb-2">
Room/Area *
</label>
<input
id="room"
name="room"
type="text"
value={formData.room}
onChange={handleChange}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent text-gray-900"
placeholder="e.g., Living Room, Kitchen"
required
/>
</div>
<div>
<label htmlFor="frequency" className="block text-sm font-medium text-gray-700 mb-2">
Frequency *
</label>
<select
id="frequency"
name="frequency"
value={formData.frequency}
onChange={handleChange}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent text-gray-900"
required
>
<option value="on_trigger">On Trigger</option>
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
<option value="fortnightly">Fortnightly</option>
<option value="monthly">Monthly</option>
</select>
</div>
<div>
<label htmlFor="points" className="block text-sm font-medium text-gray-700 mb-2">
Points
</label>
<input
id="points"
name="points"
type="number"
min="0"
value={formData.points}
onChange={handleChange}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent text-gray-900"
placeholder="5"
/>
</div>
<div>
<label htmlFor="due_date" className="block text-sm font-medium text-gray-700 mb-2">
Due Date
</label>
<input
id="due_date"
name="due_date"
type="date"
value={formData.due_date}
onChange={handleChange}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent text-gray-900"
/>
</div>
<div>
<label htmlFor="assignment_type" className="block text-sm font-medium text-gray-700 mb-2">
Assignment Type
</label>
<select
id="assignment_type"
name="assignment_type"
value={formData.assignment_type || 'any_one'}
onChange={handleChange}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent text-gray-900"
>
<option value="any_one">Any One Person (only one needs to complete)</option>
<option value="all_assigned">All Assigned (everyone must complete)</option>
</select>
<p className="text-xs text-gray-500 mt-1">
Choose if just one person OR all assigned people need to complete this chore
</p>
</div>
</div>
{/* Multi-User Assignment */}
<div className="border-t border-gray-200 pt-4">
<label className="block text-sm font-medium text-gray-700 mb-3">
Assign To (select multiple)
</label>
<div className="grid grid-cols-2 gap-2 max-h-48 overflow-y-auto border border-gray-200 rounded-lg p-3">
{users.map(user => (
<label
key={user.id}
className={`flex items-center p-2 rounded cursor-pointer transition-colors ${
formData.assigned_user_ids?.includes(user.id)
? 'bg-blue-50 border border-blue-300'
: 'hover:bg-gray-50 border border-transparent'
}`}
>
<input
type="checkbox"
checked={formData.assigned_user_ids?.includes(user.id) || false}
onChange={() => toggleUserAssignment(user.id)}
className="w-4 h-4 text-blue-600 rounded focus:ring-2 focus:ring-blue-500"
/>
<span className="ml-2 text-sm text-gray-900">{user.full_name}</span>
</label>
))}
</div>
{formData.assigned_user_ids && formData.assigned_user_ids.length > 0 && (
<p className="mt-2 text-sm text-gray-600">
{formData.assigned_user_ids.length} user(s) selected
</p>
)}
</div>
<div className="flex gap-3 pt-4">
<button
type="button"
onClick={onClose}
className="flex-1 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={isLoading}
className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLoading ? 'Creating...' : 'Create Chore'}
</button>
</div>
</form>
</div>
</div>
</div>
);
};
export default CreateChoreModal;

View File

@@ -0,0 +1,379 @@
import React, { useState, useEffect } from 'react';
import { choreService, Chore, UpdateChoreRequest } from '../api/chores';
import api from '../api/axios';
import ChoreImageUpload from './ChoreImageUpload';
interface User {
id: number;
username: string;
full_name: string;
is_active: boolean;
}
interface EditChoreModalProps {
choreId: number;
onClose: () => void;
onSuccess: () => void;
}
const EditChoreModal: React.FC<EditChoreModalProps> = ({ choreId, onClose, onSuccess }) => {
const [chore, setChore] = useState<Chore | null>(null);
const [formData, setFormData] = useState<UpdateChoreRequest>({});
const [users, setUsers] = useState<User[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
console.log('EditChoreModal: Loading chore ID:', choreId);
loadChoreAndUsers();
}, [choreId]);
const loadChoreAndUsers = async () => {
try {
console.log('EditChoreModal: Fetching chore and users...');
const [choreData, usersData] = await Promise.all([
choreService.getChore(choreId),
api.get<User[]>('/api/v1/users')
]);
console.log('EditChoreModal: Chore data loaded:', choreData);
console.log('EditChoreModal: Users loaded:', usersData.data);
setChore(choreData);
setUsers(usersData.data.filter(u => u.is_active));
// Initialize form with current chore data
const formInit = {
title: choreData.title,
description: choreData.description || '',
room: choreData.room,
frequency: choreData.frequency,
points: choreData.points,
assignment_type: choreData.assignment_type,
assigned_user_ids: choreData.assigned_users.map(u => u.id),
due_date: choreData.due_date ? choreData.due_date.split('T')[0] : '',
};
console.log('EditChoreModal: Form initialized:', formInit);
setFormData(formInit);
} catch (error: any) {
console.error('EditChoreModal: Failed to load chore:', error);
console.error('EditChoreModal: Error response:', error.response?.data);
setError(`Failed to load chore details: ${error.response?.data?.detail || error.message}`);
} finally {
setIsLoading(false);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setIsSaving(true);
console.log('EditChoreModal: Submitting update with data:', formData);
try {
const submitData = { ...formData };
if (submitData.due_date) {
submitData.due_date = `${submitData.due_date}T23:59:59`;
}
console.log('EditChoreModal: Calling API with:', submitData);
const result = await choreService.updateChore(choreId, submitData);
console.log('EditChoreModal: Update successful:', result);
onSuccess();
} catch (err: any) {
console.error('EditChoreModal: Update failed:', err);
console.error('EditChoreModal: Error response:', err.response?.data);
let errorMessage = 'Failed to update chore';
if (err.response?.data) {
const errorData = err.response.data;
if (Array.isArray(errorData.detail)) {
errorMessage = errorData.detail
.map((e: any) => `${e.loc?.join('.')}: ${e.msg}`)
.join(', ');
} else if (typeof errorData.detail === 'string') {
errorMessage = errorData.detail;
} else if (typeof errorData === 'string') {
errorMessage = errorData;
}
}
setError(errorMessage);
} finally {
setIsSaving(false);
}
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
const { name, value } = e.target;
setFormData(prev => ({
...prev,
[name]: name === 'points' ? parseInt(value) || 0 : value,
}));
};
const toggleUserAssignment = (userId: number) => {
setFormData(prev => {
const currentIds = prev.assigned_user_ids || [];
const isAssigned = currentIds.includes(userId);
const newIds = isAssigned
? currentIds.filter(id => id !== userId)
: [...currentIds, userId];
console.log('EditChoreModal: Toggling user', userId, 'new list:', newIds);
return {
...prev,
assigned_user_ids: newIds
};
});
};
if (isLoading) {
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
<div className="bg-white rounded-lg shadow-xl p-8">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
<p className="mt-4 text-gray-600">Loading chore...</p>
</div>
</div>
);
}
if (!chore) {
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
<div className="bg-white rounded-lg shadow-xl p-8">
<h3 className="text-lg font-bold text-red-600 mb-4">Error</h3>
<p className="text-gray-700 mb-4">{error || 'Failed to load chore'}</p>
<button
onClick={onClose}
className="px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700"
>
Close
</button>
</div>
</div>
);
}
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
<div className="bg-white rounded-lg shadow-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
<div className="p-6">
<div className="flex justify-between items-center mb-6">
<h2 className="text-2xl font-bold text-gray-900">Edit Chore</h2>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 transition-colors"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
<p className="font-bold">Error:</p>
<p>{error}</p>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="md:col-span-2">
<label htmlFor="title" className="block text-sm font-medium text-gray-700 mb-2">
Chore Title *
</label>
<input
id="title"
name="title"
type="text"
value={formData.title || ''}
onChange={handleChange}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent text-gray-900"
required
/>
</div>
<div className="md:col-span-2">
<label htmlFor="description" className="block text-sm font-medium text-gray-700 mb-2">
Description
</label>
<textarea
id="description"
name="description"
value={formData.description || ''}
onChange={handleChange}
rows={2}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent text-gray-900"
/>
</div>
<div>
<label htmlFor="room" className="block text-sm font-medium text-gray-700 mb-2">
Room/Area *
</label>
<input
id="room"
name="room"
type="text"
value={formData.room || ''}
onChange={handleChange}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent text-gray-900"
required
/>
</div>
<div>
<label htmlFor="frequency" className="block text-sm font-medium text-gray-700 mb-2">
Frequency *
</label>
<select
id="frequency"
name="frequency"
value={formData.frequency || 'daily'}
onChange={handleChange}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent text-gray-900"
required
>
<option value="on_trigger">On Trigger</option>
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
<option value="fortnightly">Fortnightly</option>
<option value="monthly">Monthly</option>
</select>
</div>
<div>
<label htmlFor="points" className="block text-sm font-medium text-gray-700 mb-2">
Points
</label>
<input
id="points"
name="points"
type="number"
min="0"
value={formData.points || 0}
onChange={handleChange}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent text-gray-900"
/>
</div>
<div>
<label htmlFor="due_date" className="block text-sm font-medium text-gray-700 mb-2">
Due Date
</label>
<input
id="due_date"
name="due_date"
type="date"
value={formData.due_date || ''}
onChange={handleChange}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent text-gray-900"
/>
</div>
<div>
<label htmlFor="assignment_type" className="block text-sm font-medium text-gray-700 mb-2">
Assignment Type
</label>
<select
id="assignment_type"
name="assignment_type"
value={formData.assignment_type || 'any_one'}
onChange={handleChange}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent text-gray-900"
>
<option value="any_one">Any One Person (only one needs to complete)</option>
<option value="all_assigned">All Assigned (everyone must complete)</option>
</select>
<p className="text-xs text-gray-500 mt-1">
Choose if just one person OR all assigned people need to complete this chore
</p>
</div>
</div>
{/* Chore Image Upload */}
<div className="border-t border-gray-200 pt-4">
<label className="block text-sm font-medium text-gray-700 mb-3">
Chore Image
</label>
<ChoreImageUpload
choreId={choreId}
currentImageUrl={chore?.image_url}
onUploadSuccess={(imageUrl) => {
if (chore) {
setChore({ ...chore, image_url: imageUrl });
}
}}
onDeleteSuccess={() => {
if (chore) {
setChore({ ...chore, image_url: undefined });
}
}}
/>
</div>
{/* Multi-User Assignment */}
<div className="border-t border-gray-200 pt-4">
<label className="block text-sm font-medium text-gray-700 mb-3">
Assign To (select multiple)
</label>
<div className="grid grid-cols-2 gap-2 max-h-48 overflow-y-auto border border-gray-200 rounded-lg p-3">
{users.map(user => (
<label
key={user.id}
className={`flex items-center p-2 rounded cursor-pointer transition-colors ${
formData.assigned_user_ids?.includes(user.id)
? 'bg-blue-50 border border-blue-300'
: 'hover:bg-gray-50 border border-transparent'
}`}
>
<input
type="checkbox"
checked={formData.assigned_user_ids?.includes(user.id) || false}
onChange={() => toggleUserAssignment(user.id)}
className="w-4 h-4 text-blue-600 rounded focus:ring-2 focus:ring-blue-500"
/>
<span className="ml-2 text-sm text-gray-900">{user.full_name}</span>
</label>
))}
</div>
{formData.assigned_user_ids && formData.assigned_user_ids.length > 0 && (
<p className="mt-2 text-sm text-gray-600">
{formData.assigned_user_ids.length} user(s) selected
</p>
)}
</div>
<div className="flex gap-3 pt-4">
<button
type="button"
onClick={onClose}
className="flex-1 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={isSaving}
className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{isSaving ? 'Saving...' : 'Save Changes'}
</button>
</div>
</form>
</div>
</div>
</div>
);
};
export default EditChoreModal;

View File

@@ -0,0 +1,136 @@
import React, { useState } from 'react';
import { CheckCircleIcon, XMarkIcon } from '@heroicons/react/24/outline';
interface EnhancedCompletionModalProps {
isOpen: boolean;
onClose: () => void;
onConfirm: (notes?: string) => void;
choreTitle: string;
userName: string;
}
const EnhancedCompletionModal: React.FC<EnhancedCompletionModalProps> = ({
isOpen,
onClose,
onConfirm,
choreTitle,
userName,
}) => {
const [notes, setNotes] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
if (!isOpen) return null;
const handleConfirm = async () => {
setIsSubmitting(true);
try {
await onConfirm(notes.trim() || undefined);
setNotes('');
} finally {
setIsSubmitting(false);
}
};
const handleClose = () => {
setNotes('');
onClose();
};
return (
<div className="fixed inset-0 z-50 overflow-y-auto">
{/* Backdrop */}
<div
className="fixed inset-0 bg-black bg-opacity-50 transition-opacity"
onClick={handleClose}
></div>
{/* Modal */}
<div className="flex min-h-full items-center justify-center p-4">
<div className="relative bg-white rounded-2xl shadow-xl max-w-md w-full p-6 transform transition-all">
{/* Close button */}
<button
onClick={handleClose}
className="absolute top-4 right-4 text-gray-400 hover:text-gray-600"
>
<XMarkIcon className="h-6 w-6" />
</button>
{/* Icon */}
<div className="flex justify-center mb-4">
<div className="w-16 h-16 rounded-full bg-green-100 flex items-center justify-center">
<CheckCircleIcon className="h-10 w-10 text-green-600" />
</div>
</div>
{/* Title */}
<h3 className="text-xl font-bold text-gray-900 text-center mb-2">
Complete Chore?
</h3>
{/* Chore Info */}
<div className="bg-gray-50 rounded-lg p-4 mb-4">
<p className="text-sm text-gray-600 text-center mb-1">Chore</p>
<p className="text-lg font-semibold text-gray-900 text-center">
{choreTitle}
</p>
<p className="text-sm text-gray-500 text-center mt-2">
Completed by: {userName}
</p>
</div>
{/* Notes Field */}
<div className="mb-6">
<label
htmlFor="notes"
className="block text-sm font-medium text-gray-700 mb-2"
>
Add a note (optional)
</label>
<textarea
id="notes"
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Any comments about this chore? (e.g., 'Kitchen was really messy today!')"
rows={3}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-transparent resize-none"
maxLength={200}
/>
<p className="mt-1 text-xs text-gray-500 text-right">
{notes.length}/200 characters
</p>
</div>
{/* Actions */}
<div className="flex space-x-3">
<button
onClick={handleClose}
disabled={isSubmitting}
className="flex-1 px-4 py-3 bg-gray-100 hover:bg-gray-200 text-gray-700 font-semibold rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
Cancel
</button>
<button
onClick={handleConfirm}
disabled={isSubmitting}
className="flex-1 px-4 py-3 bg-green-600 hover:bg-green-700 text-white font-semibold rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center"
>
{isSubmitting ? (
<>
<svg className="animate-spin -ml-1 mr-2 h-4 w-4 text-white" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Saving...
</>
) : (
'Complete!'
)}
</button>
</div>
</div>
</div>
</div>
);
};
export default EnhancedCompletionModal;

View File

@@ -0,0 +1,267 @@
import React, { useState, useEffect } from 'react';
import { choreLogsService, UserChoreStats } from '../api/choreLogs';
import {
TrophyIcon,
ChartBarIcon,
CalendarIcon,
HeartIcon,
ClockIcon
} from '@heroicons/react/24/outline';
interface UserStatsProps {
userId: number;
userName?: string;
compact?: boolean; // Compact mode for kiosk/dashboard views
}
const UserStats: React.FC<UserStatsProps> = ({ userId, userName, compact = false }) => {
const [stats, setStats] = useState<UserChoreStats | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
loadStats();
}, [userId]);
const loadStats = async () => {
setIsLoading(true);
setError(null);
try {
const data = await choreLogsService.getUserStats(userId);
setStats(data);
} catch (error) {
console.error('Failed to load user stats:', error);
setError('Failed to load statistics');
} finally {
setIsLoading(false);
}
};
if (isLoading) {
return (
<div className="flex items-center justify-center p-8">
<div className="text-center">
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
<p className="mt-2 text-sm text-gray-600">Loading stats...</p>
</div>
</div>
);
}
if (error || !stats) {
return (
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
<p className="text-red-800 text-sm">{error || 'No stats available'}</p>
</div>
);
}
// Compact view for kiosk/dashboard
if (compact) {
return (
<div className="bg-white rounded-lg shadow p-4">
<div className="flex items-center space-x-3 mb-4">
{stats.avatar_url ? (
<img
src={`http://10.0.0.243:8000${stats.avatar_url}`}
alt={stats.full_name || stats.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">
{(stats.full_name || stats.username).charAt(0).toUpperCase()}
</div>
)}
<div>
<h3 className="font-semibold text-gray-900">
{stats.full_name || stats.username}
</h3>
<p className="text-sm text-gray-500">Your Stats</p>
</div>
</div>
<div className="grid grid-cols-3 gap-3">
<div className="text-center p-2 bg-blue-50 rounded-lg">
<p className="text-2xl font-bold text-blue-600">
{stats.completions_this_week}
</p>
<p className="text-xs text-gray-600">This Week</p>
</div>
<div className="text-center p-2 bg-green-50 rounded-lg">
<p className="text-2xl font-bold text-green-600">
{stats.completions_this_month}
</p>
<p className="text-xs text-gray-600">This Month</p>
</div>
<div className="text-center p-2 bg-purple-50 rounded-lg">
<p className="text-2xl font-bold text-purple-600">
{stats.total_completions}
</p>
<p className="text-xs text-gray-600">All Time</p>
</div>
</div>
{stats.favorite_chore && (
<div className="mt-3 p-2 bg-yellow-50 rounded-lg text-center">
<p className="text-xs text-gray-600">Favorite Chore</p>
<p className="text-sm font-semibold text-gray-900">
{stats.favorite_chore}
</p>
</div>
)}
</div>
);
}
// Full view for dedicated stats page
return (
<div className="space-y-6">
{/* Header with Avatar */}
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center space-x-4">
{stats.avatar_url ? (
<img
src={`http://10.0.0.243:8000${stats.avatar_url}`}
alt={stats.full_name || stats.username}
className="w-20 h-20 rounded-full object-cover"
/>
) : (
<div className="w-20 h-20 rounded-full bg-gradient-to-br from-blue-400 to-purple-500 flex items-center justify-center text-white font-bold text-3xl">
{(stats.full_name || stats.username).charAt(0).toUpperCase()}
</div>
)}
<div>
<h2 className="text-2xl font-bold text-gray-900">
{stats.full_name || stats.username}
</h2>
<p className="text-gray-500">Chore Statistics</p>
</div>
</div>
</div>
{/* Stats Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{/* Total Completions */}
<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">
Total Completions
</p>
<p className="text-3xl font-bold text-gray-900">
{stats.total_completions}
</p>
</div>
</div>
</div>
{/* This Week */}
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center">
<div className="flex-shrink-0">
<CalendarIcon className="h-8 w-8 text-blue-600" />
</div>
<div className="ml-4">
<p className="text-sm font-medium text-gray-500">
This Week
</p>
<p className="text-3xl font-bold text-gray-900">
{stats.completions_this_week}
</p>
</div>
</div>
</div>
{/* This Month */}
<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-green-600" />
</div>
<div className="ml-4">
<p className="text-sm font-medium text-gray-500">
This Month
</p>
<p className="text-3xl font-bold text-gray-900">
{stats.completions_this_month}
</p>
</div>
</div>
</div>
{/* Favorite Chore */}
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center">
<div className="flex-shrink-0">
<HeartIcon className="h-8 w-8 text-pink-600" />
</div>
<div className="ml-4">
<p className="text-sm font-medium text-gray-500">
Favorite Chore
</p>
<p className="text-lg font-bold text-gray-900 truncate">
{stats.favorite_chore || 'None yet'}
</p>
</div>
</div>
</div>
</div>
{/* Recent Completions */}
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-xl font-bold text-gray-900">Recent Completions</h3>
<ClockIcon className="h-6 w-6 text-gray-400" />
</div>
<div className="space-y-3">
{stats.recent_completions.map((completion) => (
<div
key={completion.id}
className="flex items-center justify-between p-4 bg-gray-50 rounded-lg"
>
<div>
<p className="font-medium text-gray-900">
{completion.chore_title}
</p>
{completion.notes && (
<p className="text-sm text-gray-600 italic mt-1">
"{completion.notes}"
</p>
)}
{completion.verified_by_name && (
<p className="text-xs text-green-600 mt-1 flex items-center">
<svg className="h-4 w-4 mr-1" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
</svg>
Verified by {completion.verified_by_name}
</p>
)}
</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>
))}
{stats.recent_completions.length === 0 && (
<p className="text-gray-500 text-center py-8">
No completions yet. Complete some chores to see them here!
</p>
)}
</div>
</div>
</div>
);
};
export default UserStats;

View File

@@ -0,0 +1,79 @@
import React, { createContext, useContext, useState, useEffect } from 'react';
import { authService, User } from '../api/auth';
interface AuthContextType {
user: User | null;
token: string | null;
login: (username: string, password: string) => Promise<void>;
logout: () => void;
isLoading: boolean;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [user, setUser] = useState<User | null>(null);
const [token, setToken] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
// Check for existing token on mount
const storedToken = localStorage.getItem('token');
const storedUser = localStorage.getItem('user');
if (storedToken && storedUser) {
setToken(storedToken);
setUser(JSON.parse(storedUser));
// Verify token is still valid
authService.getCurrentUser()
.then(user => {
setUser(user);
localStorage.setItem('user', JSON.stringify(user));
})
.catch(() => {
// Token invalid, clear storage
localStorage.removeItem('token');
localStorage.removeItem('user');
setToken(null);
setUser(null);
})
.finally(() => setIsLoading(false));
} else {
setIsLoading(false);
}
}, []);
const login = async (username: string, password: string) => {
const response = await authService.login({ username, password });
// CRITICAL: Store token FIRST so axios interceptor can use it
localStorage.setItem('token', response.access_token);
setToken(response.access_token);
// NOW fetch user data with the token in place
const userData = await authService.getCurrentUser();
localStorage.setItem('user', JSON.stringify(userData));
setUser(userData);
};
const logout = () => {
authService.logout();
setToken(null);
setUser(null);
};
return (
<AuthContext.Provider value={{ user, token, login, logout, isLoading }}>
{children}
</AuthContext.Provider>
);
};
export const useAuth = () => {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
};

32
frontend/src/index.css Normal file
View File

@@ -0,0 +1,32 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
}
#root {
width: 100%;
margin: 0 auto;
text-align: center;
}

10
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.tsx'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)

View File

@@ -0,0 +1,378 @@
import React, { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
import { choreService, Chore } from '../api/chores';
import ChoreCard from '../components/ChoreCard';
import CreateChoreModal from '../components/CreateChoreModal';
import EditChoreModal from '../components/EditChoreModal';
import api from '../api/axios';
interface User {
id: number;
username: string;
full_name: string;
is_active: boolean;
}
const Dashboard: React.FC = () => {
const { user, logout } = useAuth();
const [chores, setChores] = useState<Chore[]>([]);
const [users, setUsers] = useState<User[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [showCreateModal, setShowCreateModal] = useState(false);
const [editingChoreId, setEditingChoreId] = useState<number | null>(null);
const [filter, setFilter] = useState<'all' | 'my' | 'today'>('all');
const [selectedUserId, setSelectedUserId] = useState<number | null>(null);
const [hideBirthdayChores, setHideBirthdayChores] = useState(false);
useEffect(() => {
loadData();
}, [selectedUserId, hideBirthdayChores]);
const loadData = async () => {
setIsLoading(true);
try {
const [choresData, usersData] = await Promise.all([
choreService.getChores({
user_id: selectedUserId || undefined,
exclude_birthdays: hideBirthdayChores
}),
api.get<User[]>('/api/v1/users')
]);
setChores(choresData);
setUsers(usersData.data.filter(u => u.is_active));
} catch (error) {
console.error('Failed to load data:', error);
} finally {
setIsLoading(false);
}
};
const handleCompleteChore = async (id: number) => {
try {
await choreService.completeChore(id);
await loadData();
} catch (error) {
console.error('Failed to complete chore:', error);
}
};
const handleDeleteChore = async (id: number) => {
if (window.confirm('Are you sure you want to delete this chore?')) {
try {
await choreService.deleteChore(id);
await loadData();
} catch (error) {
console.error('Failed to delete chore:', error);
}
}
};
const handleEditChore = (id: number) => {
setEditingChoreId(id);
};
const filteredChores = chores.filter((chore) => {
if (filter === 'my') {
return chore.assigned_users?.some(u => u.id === user?.id);
}
if (filter === 'today') {
const today = new Date().toISOString().split('T')[0];
return chore.due_date?.startsWith(today) || chore.frequency === 'daily';
}
return true;
});
// Calculate stats
const todayChores = chores.filter((chore) => {
const today = new Date().toISOString().split('T')[0];
const isToday = chore.due_date?.startsWith(today) || chore.frequency === 'daily';
const notCompleted = chore.status !== 'completed';
return isToday && notCompleted;
});
const myChores = chores.filter((chore) =>
chore.assigned_users?.some(u => u.id === user?.id) && chore.status !== 'completed'
);
const totalPoints = filteredChores.reduce((sum, chore) =>
chore.status !== 'completed' ? sum + chore.points : sum, 0
);
const myPoints = myChores.reduce((sum, chore) => sum + chore.points, 0);
return (
<div className="min-h-screen bg-gray-50">
{/* Header */}
<header className="bg-white shadow-sm">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 flex justify-between items-center">
<div>
<h1 className="text-2xl font-bold text-gray-900">Family Hub</h1>
<p className="text-sm text-gray-600">Welcome back, {user?.full_name}!</p>
</div>
<div className="flex items-center gap-3">
<Link
to="/reports"
className="px-4 py-2 text-sm text-gray-700 hover:text-gray-900 hover:bg-gray-100 rounded-lg transition-colors flex items-center gap-2"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
Reports
</Link>
<Link
to="/stats"
className="px-4 py-2 text-sm text-gray-700 hover:text-gray-900 hover:bg-gray-100 rounded-lg transition-colors flex items-center gap-2"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
My Stats
</Link>
<Link
to="/settings"
className="px-4 py-2 text-sm text-gray-700 hover:text-gray-900 hover:bg-gray-100 rounded-lg transition-colors flex items-center gap-2"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
Settings
</Link>
<button
onClick={logout}
className="px-4 py-2 text-sm text-gray-700 hover:text-gray-900 hover:bg-gray-100 rounded-lg transition-colors"
>
Sign Out
</button>
</div>
</div>
</header>
{/* Main Content */}
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Stats */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600">Today's Tasks</p>
<p className="text-3xl font-bold text-blue-600">{todayChores.length}</p>
</div>
<div className="p-3 bg-blue-100 rounded-full">
<svg className="w-8 h-8 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
</div>
</div>
</div>
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600">My Tasks</p>
<p className="text-3xl font-bold text-green-600">{myChores.length}</p>
</div>
<div className="p-3 bg-green-100 rounded-full">
<svg className="w-8 h-8 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
</div>
</div>
</div>
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600">My Points</p>
<p className="text-3xl font-bold text-amber-600">{myPoints}</p>
</div>
<div className="p-3 bg-amber-100 rounded-full">
<span className="text-3xl">⭐</span>
</div>
</div>
</div>
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600">Total Available</p>
<p className="text-3xl font-bold text-purple-600">{totalPoints}</p>
</div>
<div className="p-3 bg-purple-100 rounded-full">
<svg className="w-8 h-8 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
</div>
</div>
</div>
</div>
{/* Filters and Actions */}
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-6">
<div className="flex flex-wrap gap-2">
<button
onClick={() => { setFilter('all'); setSelectedUserId(null); }}
className={`px-4 py-2 rounded-lg transition-colors ${
filter === 'all' && !selectedUserId
? 'bg-blue-600 text-white'
: 'bg-white text-gray-700 hover:bg-gray-50'
}`}
>
All Tasks
</button>
<button
onClick={() => { setFilter('today'); setSelectedUserId(null); }}
className={`px-4 py-2 rounded-lg transition-colors ${
filter === 'today'
? 'bg-blue-600 text-white'
: 'bg-white text-gray-700 hover:bg-gray-50'
}`}
>
Today
</button>
<button
onClick={() => { setFilter('my'); setSelectedUserId(null); }}
className={`px-4 py-2 rounded-lg transition-colors ${
filter === 'my'
? 'bg-blue-600 text-white'
: 'bg-white text-gray-700 hover:bg-gray-50'
}`}
>
My Tasks
</button>
{/* User Filter Dropdown */}
<select
value={selectedUserId || ''}
onChange={(e) => {
setSelectedUserId(e.target.value ? parseInt(e.target.value) : null);
setFilter('all');
}}
className="px-4 py-2 bg-white border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50 focus:ring-2 focus:ring-blue-500"
>
<option value="">Filter by User...</option>
{users.map(u => (
<option key={u.id} value={u.id}>{u.full_name}</option>
))}
</select>
{/* Birthday Filter Toggle */}
<button
onClick={() => setHideBirthdayChores(!hideBirthdayChores)}
className={`px-4 py-2 rounded-lg transition-colors flex items-center gap-2 ${
hideBirthdayChores
? 'bg-purple-600 text-white'
: 'bg-white text-gray-700 hover:bg-gray-50 border border-gray-300'
}`}
>
<span>🎂</span>
<span>Hide Birthday Chores</span>
</button>
</div>
<button
onClick={() => setShowCreateModal(true)}
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors flex items-center gap-2 whitespace-nowrap"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
Create Task
</button>
</div>
{/* Active Filters Display */}
{(selectedUserId || hideBirthdayChores) && (
<div className="mb-4 flex flex-wrap gap-2">
{selectedUserId && (
<div className="inline-flex items-center gap-2 px-3 py-1 bg-blue-100 text-blue-800 rounded-full text-sm">
<span>User: {users.find(u => u.id === selectedUserId)?.full_name}</span>
<button
onClick={() => setSelectedUserId(null)}
className="hover:bg-blue-200 rounded-full p-0.5"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
)}
{hideBirthdayChores && (
<div className="inline-flex items-center gap-2 px-3 py-1 bg-purple-100 text-purple-800 rounded-full text-sm">
<span>🎂 Hiding birthday chores</span>
<button
onClick={() => setHideBirthdayChores(false)}
className="hover:bg-purple-200 rounded-full p-0.5"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
)}
</div>
)}
{/* Chores List */}
{isLoading ? (
<div className="text-center py-12">
<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 chores...</p>
</div>
) : filteredChores.length === 0 ? (
<div className="text-center py-12 bg-white rounded-lg shadow">
<svg className="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
<h3 className="mt-2 text-sm font-medium text-gray-900">No chores found</h3>
<p className="mt-1 text-sm text-gray-500">
{selectedUserId
? "This user has no assigned chores."
: hideBirthdayChores
? "All chores are birthday chores today! 🎂"
: "Get started by creating a new chore."}
</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{filteredChores.map((chore) => (
<ChoreCard
key={chore.id}
chore={chore}
onComplete={handleCompleteChore}
onDelete={handleDeleteChore}
onEdit={handleEditChore}
/>
))}
</div>
)}
</main>
{/* Modals */}
{showCreateModal && (
<CreateChoreModal
onClose={() => setShowCreateModal(false)}
onSuccess={() => {
setShowCreateModal(false);
loadData();
}}
/>
)}
{editingChoreId && (
<EditChoreModal
choreId={editingChoreId}
onClose={() => setEditingChoreId(null)}
onSuccess={() => {
setEditingChoreId(null);
loadData();
}}
/>
)}
</div>
);
};
export default Dashboard;

View File

@@ -0,0 +1,233 @@
import React, { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
import { choreService, Chore } from '../api/chores';
import ChoreCard from '../components/ChoreCard';
import CreateChoreModal from '../components/CreateChoreModal';
const Dashboard: React.FC = () => {
const { user, logout } = useAuth();
const [chores, setChores] = useState<Chore[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [showCreateModal, setShowCreateModal] = useState(false);
const [filter, setFilter] = useState<'all' | 'my' | 'today'>('all');
useEffect(() => {
loadChores();
}, []);
const loadChores = async () => {
try {
const data = await choreService.getChores();
setChores(data);
} catch (error) {
console.error('Failed to load chores:', error);
} finally {
setIsLoading(false);
}
};
const handleCompleteChore = async (id: number) => {
try {
await choreService.completeChore(id);
await loadChores();
} catch (error) {
console.error('Failed to complete chore:', error);
}
};
const handleDeleteChore = async (id: number) => {
if (window.confirm('Are you sure you want to delete this chore?')) {
try {
await choreService.deleteChore(id);
await loadChores();
} catch (error) {
console.error('Failed to delete chore:', error);
}
}
};
const filteredChores = chores.filter((chore) => {
if (filter === 'my') {
return chore.assigned_to === user?.id;
}
if (filter === 'today') {
const today = new Date().toISOString().split('T')[0];
return chore.due_date === today || chore.frequency === 'daily';
}
return true;
});
const todayChores = chores.filter((chore) => {
const today = new Date().toISOString().split('T')[0];
return (chore.due_date === today || chore.frequency === 'daily') && chore.status !== 'completed';
});
const myChores = chores.filter((chore) => chore.assigned_to === user?.id && chore.status !== 'completed');
return (
<div className="min-h-screen bg-gray-50">
{/* Header */}
<header className="bg-white shadow-sm">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 flex justify-between items-center">
<div>
<h1 className="text-2xl font-bold text-gray-900">Family Hub</h1>
<p className="text-sm text-gray-600">Welcome back, {user?.full_name}!</p>
</div>
<div className="flex items-center gap-3">
<Link
to="/settings"
className="px-4 py-2 text-sm text-gray-700 hover:text-gray-900 hover:bg-gray-100 rounded-lg transition-colors flex items-center gap-2"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
Settings
</Link>
<button
onClick={logout}
className="px-4 py-2 text-sm text-gray-700 hover:text-gray-900 hover:bg-gray-100 rounded-lg transition-colors"
>
Sign Out
</button>
</div>
</div>
</header>
{/* Main Content */}
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Stats */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600">Today's Tasks</p>
<p className="text-3xl font-bold text-blue-600">{todayChores.length}</p>
</div>
<div className="p-3 bg-blue-100 rounded-full">
<svg className="w-8 h-8 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
</div>
</div>
</div>
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600">My Tasks</p>
<p className="text-3xl font-bold text-green-600">{myChores.length}</p>
</div>
<div className="p-3 bg-green-100 rounded-full">
<svg className="w-8 h-8 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
</div>
</div>
</div>
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600">Total Tasks</p>
<p className="text-3xl font-bold text-purple-600">{chores.length}</p>
</div>
<div className="p-3 bg-purple-100 rounded-full">
<svg className="w-8 h-8 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
</div>
</div>
</div>
</div>
{/* Actions */}
<div className="flex justify-between items-center mb-6">
<div className="flex gap-2">
<button
onClick={() => setFilter('all')}
className={`px-4 py-2 rounded-lg transition-colors ${
filter === 'all'
? 'bg-blue-600 text-white'
: 'bg-white text-gray-700 hover:bg-gray-50'
}`}
>
All Tasks
</button>
<button
onClick={() => setFilter('today')}
className={`px-4 py-2 rounded-lg transition-colors ${
filter === 'today'
? 'bg-blue-600 text-white'
: 'bg-white text-gray-700 hover:bg-gray-50'
}`}
>
Today
</button>
<button
onClick={() => setFilter('my')}
className={`px-4 py-2 rounded-lg transition-colors ${
filter === 'my'
? 'bg-blue-600 text-white'
: 'bg-white text-gray-700 hover:bg-gray-50'
}`}
>
My Tasks
</button>
</div>
<button
onClick={() => setShowCreateModal(true)}
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors flex items-center gap-2"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
Create Task
</button>
</div>
{/* Chores List */}
{isLoading ? (
<div className="text-center py-12">
<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 tasks...</p>
</div>
) : filteredChores.length === 0 ? (
<div className="text-center py-12 bg-white rounded-lg shadow">
<svg className="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
<h3 className="mt-2 text-sm font-medium text-gray-900">No tasks</h3>
<p className="mt-1 text-sm text-gray-500">Get started by creating a new task.</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{filteredChores.map((chore) => (
<ChoreCard
key={chore.id}
chore={chore}
onComplete={handleCompleteChore}
onDelete={handleDeleteChore}
/>
))}
</div>
)}
</main>
{/* Create Chore Modal */}
{showCreateModal && (
<CreateChoreModal
onClose={() => setShowCreateModal(false)}
onSuccess={() => {
setShowCreateModal(false);
loadChores();
}}
/>
)}
</div>
);
};
export default Dashboard;

View File

@@ -0,0 +1,670 @@
import React, { useState, useEffect } from 'react';
import api from '../api/axios';
import { Chore } from '../api/chores';
import { API_BASE_URL } from '../api/axios';
import { getUserColor, getInitials } from '../utils/avatarUtils';
interface User {
id: number;
username: string;
full_name: string;
avatar_url?: string;
birthday?: string;
is_active: boolean;
}
interface CompletionModalState {
chore: Chore;
show: boolean;
}
const KioskView: React.FC = () => {
const [users, setUsers] = useState<User[]>([]);
const [selectedUser, setSelectedUser] = useState<User | null>(null);
const [chores, setChores] = useState<Chore[]>([]);
const [availableChores, setAvailableChores] = useState<Chore[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [excludeBirthdays, setExcludeBirthdays] = useState(true);
const [showAvailableChores, setShowAvailableChores] = useState(false);
const [completionModal, setCompletionModal] = useState<CompletionModalState | null>(null);
const [selectedHelpers, setSelectedHelpers] = useState<number[]>([]);
useEffect(() => {
loadUsers();
}, []);
useEffect(() => {
if (selectedUser) {
loadChores();
loadAvailableChores();
}
}, [selectedUser, excludeBirthdays]);
const loadUsers = async () => {
try {
const response = await api.get<User[]>('/api/v1/public/users');
setUsers(response.data.filter(u => u.is_active));
} catch (error) {
console.error('Failed to load users:', error);
}
};
const loadChores = async () => {
if (!selectedUser) return;
setIsLoading(true);
try {
const params: any = {
user_id: selectedUser.id,
};
if (excludeBirthdays) {
params.exclude_birthdays = true;
}
const response = await api.get<Chore[]>('/api/v1/public/chores', { params });
setChores(response.data);
} catch (error) {
console.error('Failed to load chores:', error);
} finally {
setIsLoading(false);
}
};
const loadAvailableChores = async () => {
if (!selectedUser) return;
try {
// Get ALL chores (no user filter)
const response = await api.get<Chore[]>('/api/v1/public/chores', {
params: { exclude_birthdays: excludeBirthdays }
});
// Filter out chores already assigned to current user
const unassignedChores = response.data.filter(chore =>
!chore.assigned_users.some(u => u.id === selectedUser.id) &&
chore.status !== 'completed'
);
setAvailableChores(unassignedChores);
} catch (error) {
console.error('Failed to load available chores:', error);
}
};
const handleClaimAndComplete = async (choreId: number) => {
if (!selectedUser) return;
try {
// First claim the chore
await api.post(`/api/v1/public/chores/${choreId}/claim`, null, {
params: { user_id: selectedUser.id }
});
// Reload chores
await loadChores();
await loadAvailableChores();
} catch (error) {
console.error('Failed to claim chore:', error);
}
};
const openCompletionModal = (chore: Chore) => {
setCompletionModal({ chore, show: true });
setSelectedHelpers([]);
};
const closeCompletionModal = () => {
setCompletionModal(null);
setSelectedHelpers([]);
};
const handleCompleteChore = async (withHelp: boolean) => {
if (!selectedUser || !completionModal) return;
try {
const params: any = { user_id: selectedUser.id };
if (withHelp && selectedHelpers.length > 0) {
// Pass helper_ids as query parameters
await api.post(
`/api/v1/public/chores/${completionModal.chore.id}/complete`,
null,
{
params: {
user_id: selectedUser.id,
helper_ids: selectedHelpers
},
paramsSerializer: params => {
const searchParams = new URLSearchParams();
searchParams.append('user_id', params.user_id);
params.helper_ids?.forEach((id: number) => searchParams.append('helper_ids', id.toString()));
return searchParams.toString();
}
}
);
} else {
// Complete without helpers
await api.post(`/api/v1/public/chores/${completionModal.chore.id}/complete`, null, {
params: { user_id: selectedUser.id }
});
}
closeCompletionModal();
await loadChores();
await loadAvailableChores();
} catch (error) {
console.error('Failed to complete chore:', error);
}
};
const toggleHelper = (userId: number) => {
setSelectedHelpers(prev =>
prev.includes(userId)
? prev.filter(id => id !== userId)
: [...prev, userId]
);
};
const handleSelectUser = (user: User) => {
setSelectedUser(user);
};
const handleBackToUsers = () => {
setSelectedUser(null);
setChores([]);
setAvailableChores([]);
setShowAvailableChores(false);
};
// Check if today is user's birthday
const isBirthday = (user: User) => {
if (!user.birthday) return false;
const today = new Date();
const bday = new Date(user.birthday);
return bday.getMonth() === today.getMonth() && bday.getDate() === today.getDate();
};
// Calculate user's points
const getUserPoints = () => {
const earned = chores
.filter(c => c.assigned_users?.some(u => u.id === selectedUser?.id && u.completed_at))
.reduce((sum, c) => sum + c.points, 0);
const available = chores
.filter(c => !c.assigned_users?.some(u => u.id === selectedUser?.id && u.completed_at))
.reduce((sum, c) => sum + c.points, 0);
return { earned, available };
};
// Get frequency icon
const getFrequencyIcon = (frequency: string) => {
switch (frequency) {
case 'daily': return '📅';
case 'weekly': return '📆';
case 'fortnightly': return '🗓️';
case 'monthly': return '📊';
case 'on_trigger': return '⏱️';
default: return '📋';
}
};
// Get assignment type badge
const getAssignmentTypeBadge = (type: string) => {
if (type === 'all_assigned') {
return (
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-purple-100 text-purple-800 border border-purple-200">
👥 All Must Complete
</span>
);
}
return (
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800 border border-blue-200">
👤 Any One Person
</span>
);
};
// Separate pending and completed chores
const pendingChores = chores.filter(c =>
!c.assigned_users?.some(u => u.id === selectedUser?.id && u.completed_at)
);
const completedChores = chores.filter(c =>
c.assigned_users?.some(u => u.id === selectedUser?.id && u.completed_at)
);
// User Selection Screen
if (!selectedUser) {
return (
<div className="min-h-screen bg-gradient-to-br from-black via-gray-900 to-black text-white p-8">
<div className="max-w-4xl mx-auto">
{/* Header */}
<div className="text-center mb-12">
<h1 className="text-5xl font-bold mb-4 bg-gradient-to-r from-blue-400 to-purple-500 bg-clip-text text-transparent">
Family Chore Hub
</h1>
<p className="text-2xl text-gray-300">Select your name to continue</p>
</div>
{/* User Cards */}
<div className="grid grid-cols-1 gap-6">
{users.map(user => (
<button
key={user.id}
onClick={() => handleSelectUser(user)}
className="group relative bg-gradient-to-r from-gray-800 to-gray-700 hover:from-gray-700 hover:to-gray-600 rounded-2xl p-6 transition-all transform hover:scale-105 hover:shadow-2xl hover:shadow-blue-500/20 border border-gray-600 hover:border-blue-500"
>
<div className="flex items-center gap-6">
{/* Avatar */}
{user.avatar_url ? (
<img
src={`${API_BASE_URL}${user.avatar_url}`}
alt={user.full_name}
className="w-24 h-24 rounded-full object-cover border-4 border-gray-600 group-hover:border-blue-500 transition-colors shadow-lg"
/>
) : (
<div className={`w-24 h-24 rounded-full ${getUserColor(user.full_name)} flex items-center justify-center text-3xl font-bold text-white border-4 border-gray-600 group-hover:border-blue-500 transition-colors shadow-lg`}>
{getInitials(user.full_name)}
</div>
)}
{/* User Info */}
<div className="flex-1 text-left">
<h3 className="text-3xl font-bold text-white group-hover:text-blue-400 transition-colors">
{user.full_name}
</h3>
<p className="text-xl text-gray-400 mt-1">@{user.username}</p>
{isBirthday(user) && (
<div className="mt-2 inline-flex items-center px-4 py-2 rounded-full bg-gradient-to-r from-yellow-500 to-orange-500 text-white font-bold shadow-lg animate-pulse">
🎂 Happy Birthday! 🎉
</div>
)}
</div>
{/* Arrow */}
<svg className="w-12 h-12 text-gray-400 group-hover:text-blue-400 transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M9 5l7 7-7 7" />
</svg>
</div>
</button>
))}
</div>
</div>
</div>
);
}
// Chores View
const points = getUserPoints();
return (
<div className="min-h-screen bg-gradient-to-br from-black via-gray-900 to-black text-white">
{/* Header */}
<div className="bg-gradient-to-r from-gray-800 to-gray-700 border-b border-gray-600 shadow-xl">
<div className="max-w-4xl mx-auto p-6">
<div className="flex items-center justify-between mb-4">
<button
onClick={handleBackToUsers}
className="flex items-center gap-3 px-6 py-3 bg-gray-700 hover:bg-gray-600 rounded-xl transition-all text-xl font-medium border border-gray-500 hover:border-gray-400"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
Back
</button>
<div className="flex items-center gap-4">
{selectedUser.avatar_url ? (
<img
src={`${API_BASE_URL}${selectedUser.avatar_url}`}
alt={selectedUser.full_name}
className="w-16 h-16 rounded-full object-cover border-3 border-blue-500 shadow-lg"
/>
) : (
<div className={`w-16 h-16 rounded-full ${getUserColor(selectedUser.full_name)} flex items-center justify-center text-2xl font-bold text-white border-3 border-blue-500 shadow-lg`}>
{getInitials(selectedUser.full_name)}
</div>
)}
<div className="text-right">
<h2 className="text-3xl font-bold">{selectedUser.full_name}</h2>
<p className="text-lg text-gray-300">@{selectedUser.username}</p>
</div>
</div>
</div>
{/* Points and Birthday Filter */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4 text-xl">
<div className="px-6 py-3 bg-gradient-to-r from-yellow-500 to-orange-500 rounded-xl font-bold shadow-lg">
{points.earned} Points Earned
</div>
<div className="px-6 py-3 bg-gradient-to-r from-blue-500 to-purple-500 rounded-xl font-bold shadow-lg">
💎 {points.available} Available
</div>
</div>
<label className="flex items-center gap-3 cursor-pointer px-6 py-3 bg-gray-700 hover:bg-gray-600 rounded-xl transition-all text-xl border border-gray-500">
<input
type="checkbox"
checked={excludeBirthdays}
onChange={(e) => setExcludeBirthdays(e.target.checked)}
className="w-6 h-6 rounded"
/>
<span>🎂 Hide birthday chores</span>
</label>
</div>
</div>
</div>
{/* Chores Content */}
<div className="max-w-4xl mx-auto p-6 space-y-6">
{isLoading ? (
<div className="text-center py-20">
<div className="animate-spin rounded-full h-20 w-20 border-b-4 border-blue-500 mx-auto mb-6"></div>
<p className="text-2xl text-gray-400">Loading chores...</p>
</div>
) : (
<>
{/* My Pending Chores */}
<div>
<h3 className="text-3xl font-bold mb-4 text-blue-400">My Chores</h3>
{pendingChores.length === 0 ? (
<div className="bg-gradient-to-r from-gray-800 to-gray-700 rounded-2xl p-12 text-center border border-gray-600">
<p className="text-3xl text-gray-400">🎉 No pending chores!</p>
<p className="text-xl text-gray-500 mt-3">Great job! Check available chores below.</p>
</div>
) : (
<div className="space-y-4">
{pendingChores.map(chore => {
const myAssignment = chore.assigned_users.find(u => u.id === selectedUser.id);
const otherUsers = chore.assigned_users.filter(u => u.id !== selectedUser.id);
return (
<div
key={chore.id}
className="bg-gradient-to-r from-gray-800 to-gray-700 rounded-2xl p-6 border border-gray-600 hover:border-blue-500 transition-all shadow-lg"
>
<div className="flex items-start gap-6">
{/* Chore Image */}
{chore.image_url && (
<img
src={`${API_BASE_URL}${chore.image_url}`}
alt={chore.title}
className="w-24 h-24 rounded-xl object-cover border-2 border-gray-600"
/>
)}
<div className="flex-1">
{/* Title and Assignment Type */}
<div className="flex items-start justify-between mb-3">
<div>
<h4 className="text-2xl font-bold text-white mb-2">{chore.title}</h4>
<div className="flex items-center gap-2 flex-wrap">
{getAssignmentTypeBadge(chore.assignment_type)}
<span className="text-lg text-gray-400">
{getFrequencyIcon(chore.frequency)} {chore.room}
</span>
</div>
</div>
<div className="text-3xl font-bold text-yellow-400">
{chore.points}
</div>
</div>
{chore.description && (
<p className="text-lg text-gray-300 mb-3">{chore.description}</p>
)}
{/* Other Assigned Users */}
{otherUsers.length > 0 && (
<div className="flex items-center gap-2 mb-4 flex-wrap">
<span className="text-gray-400">Also assigned to:</span>
{otherUsers.map(u => (
<span
key={u.id}
className={`px-3 py-1 rounded-full text-sm font-medium ${
u.completed_at
? 'bg-green-900 text-green-200 border border-green-700'
: 'bg-gray-700 text-gray-300 border border-gray-500'
}`}
>
{u.full_name} {u.completed_at && '✓'}
</span>
))}
</div>
)}
{/* Complete Button */}
<button
onClick={() => openCompletionModal(chore)}
className="w-full px-6 py-4 bg-gradient-to-r from-green-600 to-green-500 hover:from-green-500 hover:to-green-400 rounded-xl font-bold text-xl transition-all shadow-lg hover:shadow-green-500/50 border-2 border-green-400"
>
Mark Complete
</button>
</div>
</div>
</div>
);
})}
</div>
)}
</div>
{/* Available Chores Section */}
{availableChores.length > 0 && (
<div className="mt-8">
<button
onClick={() => setShowAvailableChores(!showAvailableChores)}
className="w-full bg-gradient-to-r from-purple-800 to-purple-700 hover:from-purple-700 hover:to-purple-600 rounded-2xl p-6 transition-all border border-purple-500 shadow-lg"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<span className="text-3xl">🎯</span>
<div className="text-left">
<h3 className="text-2xl font-bold text-white">Available Chores</h3>
<p className="text-lg text-purple-200">
{availableChores.length} chore{availableChores.length !== 1 ? 's' : ''} you can help with
</p>
</div>
</div>
<svg
className={`w-8 h-8 transition-transform ${showAvailableChores ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M19 9l-7 7-7-7" />
</svg>
</div>
</button>
{showAvailableChores && (
<div className="mt-4 space-y-4">
{availableChores.map(chore => (
<div
key={chore.id}
className="bg-gradient-to-r from-gray-800 to-gray-700 rounded-2xl p-6 border border-purple-500 shadow-lg"
>
<div className="flex items-start gap-6">
{chore.image_url && (
<img
src={`${API_BASE_URL}${chore.image_url}`}
alt={chore.title}
className="w-24 h-24 rounded-xl object-cover border-2 border-gray-600"
/>
)}
<div className="flex-1">
<div className="flex items-start justify-between mb-3">
<div>
<h4 className="text-2xl font-bold text-white mb-2">{chore.title}</h4>
<div className="flex items-center gap-2 flex-wrap">
{getAssignmentTypeBadge(chore.assignment_type)}
<span className="text-lg text-gray-400">
{getFrequencyIcon(chore.frequency)} {chore.room}
</span>
</div>
</div>
<div className="text-3xl font-bold text-yellow-400">
{chore.points}
</div>
</div>
{chore.description && (
<p className="text-lg text-gray-300 mb-3">{chore.description}</p>
)}
{/* Assigned Users */}
{chore.assigned_users.length > 0 && (
<div className="flex items-center gap-2 mb-4 flex-wrap">
<span className="text-gray-400">Assigned to:</span>
{chore.assigned_users.map(u => (
<span
key={u.id}
className="px-3 py-1 bg-gray-700 text-gray-300 rounded-full text-sm font-medium border border-gray-500"
>
{u.full_name}
</span>
))}
</div>
)}
{/* Claim and Complete Button */}
<button
onClick={() => handleClaimAndComplete(chore.id)}
className="w-full px-6 py-4 bg-gradient-to-r from-purple-600 to-purple-500 hover:from-purple-500 hover:to-purple-400 rounded-xl font-bold text-xl transition-all shadow-lg hover:shadow-purple-500/50 border-2 border-purple-400"
>
🎯 I'll Do This!
</button>
</div>
</div>
</div>
))}
</div>
)}
</div>
)}
{/* Completed Chores */}
{completedChores.length > 0 && (
<div className="mt-8">
<h3 className="text-3xl font-bold mb-4 text-green-400">✓ Completed</h3>
<div className="space-y-4">
{completedChores.map(chore => {
const myAssignment = chore.assigned_users.find(u => u.id === selectedUser.id);
return (
<div
key={chore.id}
className="bg-gradient-to-r from-green-900/30 to-green-800/30 rounded-2xl p-6 border border-green-700 opacity-75"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="text-4xl">✓</div>
<div>
<h4 className="text-2xl font-bold text-white">{chore.title}</h4>
<p className="text-lg text-gray-400">{chore.room}</p>
</div>
</div>
<div className="text-2xl font-bold text-green-400">
+{chore.points} ⭐
</div>
</div>
</div>
);
})}
</div>
</div>
)}
</>
)}
</div>
{/* Completion Modal */}
{completionModal && (
<div className="fixed inset-0 bg-black/80 backdrop-blur-sm flex items-center justify-center p-6 z-50">
<div className="bg-gradient-to-br from-gray-800 to-gray-700 rounded-3xl shadow-2xl max-w-2xl w-full border-2 border-blue-500 max-h-[90vh] overflow-y-auto">
<div className="p-8">
{/* Modal Header */}
<div className="text-center mb-8">
<div className="text-6xl mb-4">🎉</div>
<h2 className="text-4xl font-bold text-white mb-2">Complete Chore?</h2>
<h3 className="text-2xl text-blue-400 font-semibold">{completionModal.chore.title}</h3>
</div>
{/* Did Anyone Help? */}
<div className="mb-8">
<h4 className="text-2xl font-bold text-white mb-4 text-center">Did anyone help you?</h4>
<div className="grid grid-cols-2 gap-3">
{users
.filter(u => u.id !== selectedUser?.id)
.map(user => (
<button
key={user.id}
onClick={() => toggleHelper(user.id)}
className={`p-4 rounded-xl transition-all border-2 ${
selectedHelpers.includes(user.id)
? 'bg-blue-600 border-blue-400 shadow-lg shadow-blue-500/50'
: 'bg-gray-700 border-gray-500 hover:border-gray-400'
}`}
>
<div className="flex items-center gap-3">
{user.avatar_url ? (
<img
src={`${API_BASE_URL}${user.avatar_url}`}
alt={user.full_name}
className="w-12 h-12 rounded-full object-cover"
/>
) : (
<div className={`w-12 h-12 rounded-full ${getUserColor(user.full_name)} flex items-center justify-center text-lg font-bold text-white`}>
{getInitials(user.full_name)}
</div>
)}
<div className="text-left flex-1">
<p className="font-bold text-white text-lg">{user.full_name}</p>
</div>
{selectedHelpers.includes(user.id) && (
<div className="text-2xl">✓</div>
)}
</div>
</button>
))}
</div>
</div>
{/* Action Buttons */}
<div className="space-y-3">
<button
onClick={() => handleCompleteChore(false)}
className="w-full px-8 py-5 bg-gradient-to-r from-green-600 to-green-500 hover:from-green-500 hover:to-green-400 rounded-xl font-bold text-2xl transition-all shadow-lg border-2 border-green-400"
>
✓ I Did It Alone
</button>
{selectedHelpers.length > 0 && (
<button
onClick={() => handleCompleteChore(true)}
className="w-full px-8 py-5 bg-gradient-to-r from-blue-600 to-blue-500 hover:from-blue-500 hover:to-blue-400 rounded-xl font-bold text-2xl transition-all shadow-lg border-2 border-blue-400"
>
👥 We Did It Together ({selectedHelpers.length} helper{selectedHelpers.length !== 1 ? 's' : ''})
</button>
)}
<button
onClick={closeCompletionModal}
className="w-full px-8 py-5 bg-gray-700 hover:bg-gray-600 rounded-xl font-bold text-2xl transition-all border-2 border-gray-500"
>
Cancel
</button>
</div>
</div>
</div>
</div>
)}
</div>
);
};
export default KioskView;

View File

@@ -0,0 +1,92 @@
import React, { useState } from 'react';
import { useAuth } from '../contexts/AuthContext';
import { useNavigate } from 'react-router-dom';
const Login: React.FC = () => {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const { login } = useAuth();
const navigate = useNavigate();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setIsLoading(true);
try {
await login(username, password);
navigate('/dashboard');
} catch (err: any) {
setError(err.response?.data?.detail || 'Invalid username or password');
} finally {
setIsLoading(false);
}
};
return (
<div className="min-h-screen bg-gradient-to-br from-blue-500 to-purple-600 flex items-center justify-center p-4">
<div className="bg-white rounded-lg shadow-xl p-8 w-full max-w-md">
<div className="text-center mb-8">
<h1 className="text-3xl font-bold text-gray-800 mb-2">Family Hub</h1>
<p className="text-gray-600">Welcome back! Please sign in.</p>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
{error}
</div>
)}
<div>
<label htmlFor="username" className="block text-sm font-medium text-gray-700 mb-2">
Username
</label>
<input
id="username"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent text-gray-900"
placeholder="Enter your username"
required
autoComplete="username"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-2">
Password
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent text-gray-900"
placeholder="Enter your password"
required
autoComplete="current-password"
/>
</div>
<button
type="submit"
disabled={isLoading}
className="w-full bg-blue-600 text-white py-2 px-4 rounded-lg hover:bg-blue-700 focus:ring-4 focus:ring-blue-300 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{isLoading ? 'Signing in...' : 'Sign In'}
</button>
</form>
<div className="mt-6 text-center text-sm text-gray-600">
<p>Default password: <span className="font-mono bg-gray-100 px-2 py-1 rounded">password123</span></p>
</div>
</div>
</div>
);
};
export default Login;

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;

View File

@@ -0,0 +1,625 @@
import React, { useState, useEffect } from 'react';
import { useAuth } from '../contexts/AuthContext';
import api, { API_BASE_URL } from '../api/axios';
import AvatarUpload from '../components/AvatarUpload';
import { getUserColor, getInitials } from '../utils/avatarUtils';
interface UserProfile {
id: number;
username: string;
email: string;
full_name: string;
discord_id?: string;
profile_picture?: string;
avatar_url?: string;
birthday?: string;
is_admin: boolean;
is_active: boolean;
}
interface UpdateProfileData {
email?: string;
full_name?: string;
discord_id?: string;
profile_picture?: string;
birthday?: string;
password?: string;
}
interface AdminUpdateData extends UpdateProfileData {
is_admin?: boolean;
is_active?: boolean;
}
const Settings: React.FC = () => {
const { user } = useAuth();
const [activeTab, setActiveTab] = useState<'profile' | 'password' | 'users'>('profile');
const [profile, setProfile] = useState<UserProfile | null>(null);
const [formData, setFormData] = useState<UpdateProfileData>({});
const [confirmPassword, setConfirmPassword] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const [allUsers, setAllUsers] = useState<UserProfile[]>([]);
const [selectedUser, setSelectedUser] = useState<UserProfile | null>(null);
const [editFormData, setEditFormData] = useState<AdminUpdateData>({});
useEffect(() => {
loadProfile();
if (user?.is_admin) {
loadAllUsers();
}
}, [user]);
const loadProfile = async () => {
try {
const response = await api.get<UserProfile>('/api/v1/auth/me');
setProfile(response.data);
setFormData({
email: response.data.email,
full_name: response.data.full_name,
discord_id: response.data.discord_id || '',
profile_picture: response.data.profile_picture || '',
birthday: response.data.birthday || '',
});
} catch (err) {
console.error('Failed to load profile:', err);
setError('Failed to load profile');
}
};
const loadAllUsers = async () => {
try {
const response = await api.get<UserProfile[]>('/api/v1/users');
setAllUsers(response.data);
} catch (err) {
console.error('Failed to load users:', err);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setSuccess('');
setIsLoading(true);
try {
const updateData: UpdateProfileData = {};
if (formData.email !== profile?.email) updateData.email = formData.email;
if (formData.full_name !== profile?.full_name) updateData.full_name = formData.full_name;
if (formData.discord_id !== profile?.discord_id) updateData.discord_id = formData.discord_id;
if (formData.birthday !== profile?.birthday) updateData.birthday = formData.birthday;
await api.put('/api/v1/auth/me', updateData);
setSuccess('Profile updated successfully!');
loadProfile();
} catch (err: any) {
setError(err.response?.data?.detail || 'Failed to update profile');
} finally {
setIsLoading(false);
}
};
const handlePasswordChange = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setSuccess('');
if (formData.password !== confirmPassword) {
setError('Passwords do not match');
return;
}
setIsLoading(true);
try {
await api.put('/api/v1/auth/me', { password: formData.password });
setSuccess('Password changed successfully!');
setFormData({ ...formData, password: '' });
setConfirmPassword('');
} catch (err: any) {
setError(err.response?.data?.detail || 'Failed to change password');
} finally {
setIsLoading(false);
}
};
const handleAdminUpdateUser = async (userId: number, updateData: AdminUpdateData) => {
try {
await api.put(`/api/v1/auth/users/${userId}`, updateData);
setSuccess('User updated successfully!');
setSelectedUser(null);
loadAllUsers();
} catch (err: any) {
setError(err.response?.data?.detail || 'Failed to update user');
}
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
};
const openEditModal = (u: UserProfile) => {
setSelectedUser(u);
setEditFormData({
email: u.email,
full_name: u.full_name,
discord_id: u.discord_id || '',
birthday: u.birthday || '',
is_admin: u.is_admin,
is_active: u.is_active,
});
};
const handleEditFormChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value, type, checked } = e.target;
setEditFormData(prev => ({
...prev,
[name]: type === 'checkbox' ? checked : value
}));
};
const submitUserEdit = async (e: React.FormEvent) => {
e.preventDefault();
if (selectedUser) {
await handleAdminUpdateUser(selectedUser.id, editFormData);
}
};
if (!profile) {
return <div className="text-center py-8">Loading...</div>;
}
return (
<div className="max-w-6xl mx-auto p-6">
<h1 className="text-3xl font-bold text-gray-900 mb-8">Settings</h1>
{/* Tab Navigation */}
<div className="bg-white rounded-lg shadow-md mb-6">
<div className="border-b border-gray-200">
<nav className="flex -mb-px">
<button
onClick={() => setActiveTab('profile')}
className={`px-6 py-4 text-sm font-medium border-b-2 transition-colors ${
activeTab === 'profile'
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
<div className="flex items-center gap-2">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
My Profile
</div>
</button>
<button
onClick={() => setActiveTab('password')}
className={`px-6 py-4 text-sm font-medium border-b-2 transition-colors ${
activeTab === 'password'
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
<div className="flex items-center gap-2">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
Change Password
</div>
</button>
{user?.is_admin && (
<button
onClick={() => setActiveTab('users')}
className={`px-6 py-4 text-sm font-medium border-b-2 transition-colors ${
activeTab === 'users'
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
<div className="flex items-center gap-2">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
User Management
<span className="ml-1 bg-purple-100 text-purple-800 text-xs px-2 py-0.5 rounded">Admin</span>
</div>
</button>
)}
</nav>
</div>
{/* Tab Content */}
<div className="p-6">
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded mb-4">
{error}
</div>
)}
{success && (
<div className="bg-green-50 border border-green-200 text-green-700 px-4 py-3 rounded mb-4">
{success}
</div>
)}
{/* Profile Tab */}
{activeTab === 'profile' && (
<form onSubmit={handleSubmit} className="space-y-6">
<div className="grid md:grid-cols-2 gap-6">
<div className="md:col-span-2">
<label className="block text-sm font-medium text-gray-700 mb-3">
Profile Avatar
</label>
<AvatarUpload
currentAvatarUrl={profile?.avatar_url}
onUploadSuccess={(avatarUrl) => {
setProfile(prev => prev ? { ...prev, avatar_url: avatarUrl } : null);
setSuccess('Avatar uploaded successfully!');
}}
onDeleteSuccess={() => {
setProfile(prev => prev ? { ...prev, avatar_url: undefined } : null);
setSuccess('Avatar deleted successfully!');
}}
/>
</div>
<div className="md:col-span-2">
<label className="block text-sm font-medium text-gray-700 mb-2">
Username
</label>
<input
type="text"
value={profile.username}
disabled
className="w-full px-4 py-2 border border-gray-300 rounded-lg bg-gray-100 text-gray-500 cursor-not-allowed"
/>
<p className="text-sm text-gray-500 mt-1">Username cannot be changed</p>
</div>
<div>
<label htmlFor="full_name" className="block text-sm font-medium text-gray-700 mb-2">
Full Name
</label>
<input
id="full_name"
name="full_name"
type="text"
value={formData.full_name || ''}
onChange={handleChange}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent text-gray-900"
/>
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-2">
Email Address
</label>
<input
id="email"
name="email"
type="email"
value={formData.email || ''}
onChange={handleChange}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent text-gray-900"
/>
</div>
<div>
<label htmlFor="birthday" className="block text-sm font-medium text-gray-700 mb-2">
Birthday 🎂
</label>
<input
id="birthday"
name="birthday"
type="date"
value={formData.birthday || ''}
onChange={handleChange}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent text-gray-900"
/>
<p className="text-sm text-gray-500 mt-1">Get a break from chores on your birthday!</p>
</div>
<div>
<label htmlFor="discord_id" className="block text-sm font-medium text-gray-700 mb-2">
Discord ID
</label>
<input
id="discord_id"
name="discord_id"
type="text"
value={formData.discord_id || ''}
onChange={handleChange}
placeholder="e.g., YourName#1234"
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent text-gray-900"
/>
</div>
</div>
<div className="pt-4">
<button
type="submit"
disabled={isLoading}
className="w-full px-4 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed font-medium"
>
{isLoading ? 'Saving...' : 'Save Changes'}
</button>
</div>
</form>
)}
{/* Password Tab */}
{activeTab === 'password' && (
<form onSubmit={handlePasswordChange} className="space-y-6 max-w-md">
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-2">
New Password
</label>
<input
id="password"
name="password"
type="password"
value={formData.password || ''}
onChange={handleChange}
placeholder="Enter new password"
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent text-gray-900"
required
/>
</div>
<div>
<label htmlFor="confirmPassword" className="block text-sm font-medium text-gray-700 mb-2">
Confirm New Password
</label>
<input
id="confirmPassword"
name="confirmPassword"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="Confirm new password"
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent text-gray-900"
required
/>
</div>
<div className="pt-4">
<button
type="submit"
disabled={isLoading}
className="w-full px-4 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed font-medium"
>
{isLoading ? 'Changing Password...' : 'Change Password'}
</button>
</div>
</form>
)}
{/* User Management Tab */}
{activeTab === 'users' && user?.is_admin && (
<div className="space-y-4">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-gray-900">All Users</h3>
<span className="text-sm text-gray-500">{allUsers.length} total users</span>
</div>
<div className="grid gap-4">
{allUsers.map((u) => (
<div key={u.id} className="border border-gray-200 rounded-lg p-4 hover:border-gray-300 transition-colors">
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
{/* User Avatar */}
{u.avatar_url ? (
<img
src={`${API_BASE_URL}${u.avatar_url}`}
alt={u.full_name}
className="w-12 h-12 rounded-full object-cover border-2 border-gray-200"
/>
) : (
<div className={`w-12 h-12 rounded-full ${getUserColor(u.full_name)} flex items-center justify-center text-lg font-semibold text-white border-2 border-white shadow-md`}>
{getInitials(u.full_name)}
</div>
)}
<div>
<div className="flex items-center gap-2">
<h3 className="font-semibold text-gray-900">{u.full_name}</h3>
{u.is_admin && (
<span className="text-xs bg-purple-100 text-purple-800 px-2 py-1 rounded">Admin</span>
)}
{!u.is_active && (
<span className="text-xs bg-red-100 text-red-800 px-2 py-1 rounded">Locked</span>
)}
</div>
<p className="text-sm text-gray-500">@{u.username}</p>
<p className="text-sm text-gray-500">{u.email}</p>
{u.birthday && (
<p className="text-sm text-gray-500">🎂 {new Date(u.birthday).toLocaleDateString()}</p>
)}
{u.discord_id && (
<p className="text-sm text-gray-500">Discord: {u.discord_id}</p>
)}
</div>
</div>
<div className="flex gap-2">
{u.id !== user.id && (
<>
<button
onClick={() => handleAdminUpdateUser(u.id, { is_active: !u.is_active })}
className={`px-3 py-1 text-sm rounded transition-colors ${
u.is_active
? 'bg-yellow-100 text-yellow-800 hover:bg-yellow-200'
: 'bg-green-100 text-green-800 hover:bg-green-200'
}`}
>
{u.is_active ? 'Lock' : 'Unlock'}
</button>
<button
onClick={() => openEditModal(u)}
className="px-3 py-1 text-sm bg-blue-100 text-blue-800 hover:bg-blue-200 rounded transition-colors"
>
Edit
</button>
</>
)}
</div>
</div>
</div>
))}
</div>
</div>
)}
</div>
</div>
{/* Edit User Modal */}
{selectedUser && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-lg shadow-xl w-full max-w-2xl max-h-[90vh] overflow-y-auto">
<div className="p-6">
<div className="flex items-center justify-between mb-6">
<h2 className="text-2xl font-semibold text-gray-900">
Edit User: {selectedUser.full_name}
</h2>
<button
onClick={() => setSelectedUser(null)}
className="text-gray-400 hover:text-gray-600 transition-colors"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<form onSubmit={submitUserEdit} className="space-y-6">
{/* Avatar Upload */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-3">
Profile Avatar
</label>
<AvatarUpload
userId={selectedUser.id}
currentAvatarUrl={selectedUser.avatar_url}
onUploadSuccess={(avatarUrl) => {
setSelectedUser(prev => prev ? { ...prev, avatar_url: avatarUrl } : null);
// Refresh all users to show updated avatar
loadAllUsers();
setSuccess('Avatar uploaded successfully!');
}}
onDeleteSuccess={() => {
setSelectedUser(prev => prev ? { ...prev, avatar_url: undefined } : null);
// Refresh all users to show removed avatar
loadAllUsers();
setSuccess('Avatar deleted successfully!');
}}
/>
</div>
<div className="grid md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Full Name
</label>
<input
name="full_name"
type="text"
value={editFormData.full_name || ''}
onChange={handleEditFormChange}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 text-gray-900"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Email
</label>
<input
name="email"
type="email"
value={editFormData.email || ''}
onChange={handleEditFormChange}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 text-gray-900"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Birthday
</label>
<input
name="birthday"
type="date"
value={editFormData.birthday || ''}
onChange={handleEditFormChange}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 text-gray-900"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Discord ID
</label>
<input
name="discord_id"
type="text"
value={editFormData.discord_id || ''}
onChange={handleEditFormChange}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 text-gray-900"
/>
</div>
</div>
<div className="flex items-center gap-6 pt-2">
<label className="flex items-center">
<input
name="is_admin"
type="checkbox"
checked={editFormData.is_admin || false}
onChange={handleEditFormChange}
className="w-4 h-4 text-blue-600 rounded focus:ring-2 focus:ring-blue-500"
/>
<span className="ml-2 text-sm text-gray-700">Admin privileges</span>
</label>
<label className="flex items-center">
<input
name="is_active"
type="checkbox"
checked={editFormData.is_active || false}
onChange={handleEditFormChange}
className="w-4 h-4 text-blue-600 rounded focus:ring-2 focus:ring-blue-500"
/>
<span className="ml-2 text-sm text-gray-700">Account active</span>
</label>
</div>
<div className="flex gap-3 pt-4 border-t border-gray-200">
<button
type="submit"
className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
Save Changes
</button>
<button
type="button"
onClick={() => setSelectedUser(null)}
className="flex-1 px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 transition-colors"
>
Cancel
</button>
</div>
</form>
</div>
</div>
</div>
)}
</div>
);
};
export default Settings;

View File

@@ -0,0 +1,59 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
import UserStats from '../components/UserStats';
import { ArrowLeftIcon } from '@heroicons/react/24/outline';
const UserStatsPage: React.FC = () => {
const { user, logout } = useAuth();
if (!user) {
return null;
}
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">
My Statistics
</h1>
<p className="mt-1 text-sm text-gray-500">
Your chore completion performance
</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>
{/* Content */}
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<UserStats userId={user.id} userName={user.full_name || user.username} />
</div>
</div>
);
};
export default UserStatsPage;

View File

@@ -0,0 +1,44 @@
/**
* Generate a consistent color for a user based on their name
*/
export function getUserColor(name: string): string {
const colors = [
'bg-red-500',
'bg-orange-500',
'bg-amber-500',
'bg-yellow-500',
'bg-lime-500',
'bg-green-500',
'bg-emerald-500',
'bg-teal-500',
'bg-cyan-500',
'bg-sky-500',
'bg-blue-500',
'bg-indigo-500',
'bg-violet-500',
'bg-purple-500',
'bg-fuchsia-500',
'bg-pink-500',
'bg-rose-500',
];
// Simple hash function to get consistent color for same name
let hash = 0;
for (let i = 0; i < name.length; i++) {
hash = name.charCodeAt(i) + ((hash << 5) - hash);
}
const index = Math.abs(hash) % colors.length;
return colors[index];
}
/**
* Generate initials from a full name
*/
export function getInitials(name: string): string {
const parts = name.trim().split(' ');
if (parts.length >= 2) {
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
}
return name.slice(0, 2).toUpperCase();
}