Phase 3.1: Enhanced Chore Logging and Reporting System
This commit is contained in:
18
frontend/Dockerfile
Normal file
18
frontend/Dockerfile
Normal file
@@ -0,0 +1,18 @@
|
||||
FROM node:18-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm install
|
||||
|
||||
# Copy application files
|
||||
COPY . .
|
||||
|
||||
# Expose Vite dev server port
|
||||
EXPOSE 5173
|
||||
|
||||
# Start development server
|
||||
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
|
||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Family Hub</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
2957
frontend/package-lock.json
generated
Normal file
2957
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
27
frontend/package.json
Normal file
27
frontend/package.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "family-hub-frontend",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint . --ext js,jsx,ts,tsx"
|
||||
},
|
||||
"dependencies": {
|
||||
"@heroicons/react": "^2.2.0",
|
||||
"axios": "^1.6.2",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.20.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.43",
|
||||
"@types/react-dom": "^18.2.17",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"postcss": "^8.4.32",
|
||||
"tailwindcss": "^3.3.6",
|
||||
"vite": "^5.0.8"
|
||||
}
|
||||
}
|
||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
2
frontend/public/.gitkeep
Normal file
2
frontend/public/.gitkeep
Normal file
@@ -0,0 +1,2 @@
|
||||
# Public assets directory
|
||||
# Place your static assets here (images, icons, etc.)
|
||||
7
frontend/src/App.test.tsx
Normal file
7
frontend/src/App.test.tsx
Normal 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
120
frontend/src/App.tsx
Normal 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
46
frontend/src/api/auth.ts
Normal 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
34
frontend/src/api/axios.ts
Normal 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;
|
||||
125
frontend/src/api/choreLogs.ts
Normal file
125
frontend/src/api/choreLogs.ts
Normal 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;
|
||||
94
frontend/src/api/chores.ts
Normal file
94
frontend/src/api/chores.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
89
frontend/src/api/chores.ts.backup
Normal file
89
frontend/src/api/chores.ts.backup
Normal 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;
|
||||
},
|
||||
};
|
||||
72
frontend/src/api/uploads.ts
Normal file
72
frontend/src/api/uploads.ts
Normal 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`);
|
||||
},
|
||||
};
|
||||
168
frontend/src/components/AvatarUpload.tsx
Normal file
168
frontend/src/components/AvatarUpload.tsx
Normal 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;
|
||||
193
frontend/src/components/ChoreCard.tsx
Normal file
193
frontend/src/components/ChoreCard.tsx
Normal 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;
|
||||
154
frontend/src/components/ChoreImageUpload.tsx
Normal file
154
frontend/src/components/ChoreImageUpload.tsx
Normal 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;
|
||||
297
frontend/src/components/CreateChoreModal.tsx
Normal file
297
frontend/src/components/CreateChoreModal.tsx
Normal 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;
|
||||
379
frontend/src/components/EditChoreModal.tsx
Normal file
379
frontend/src/components/EditChoreModal.tsx
Normal 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;
|
||||
136
frontend/src/components/EnhancedCompletionModal.tsx
Normal file
136
frontend/src/components/EnhancedCompletionModal.tsx
Normal 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;
|
||||
267
frontend/src/components/UserStats.tsx
Normal file
267
frontend/src/components/UserStats.tsx
Normal 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;
|
||||
79
frontend/src/contexts/AuthContext.tsx
Normal file
79
frontend/src/contexts/AuthContext.tsx
Normal 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
32
frontend/src/index.css
Normal 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
10
frontend/src/main.tsx
Normal 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>,
|
||||
)
|
||||
378
frontend/src/pages/Dashboard.tsx
Normal file
378
frontend/src/pages/Dashboard.tsx
Normal 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;
|
||||
233
frontend/src/pages/Dashboard.tsx.backup
Normal file
233
frontend/src/pages/Dashboard.tsx.backup
Normal 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;
|
||||
670
frontend/src/pages/KioskView.tsx
Normal file
670
frontend/src/pages/KioskView.tsx
Normal 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;
|
||||
92
frontend/src/pages/Login.tsx
Normal file
92
frontend/src/pages/Login.tsx
Normal 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;
|
||||
400
frontend/src/pages/Reports.tsx
Normal file
400
frontend/src/pages/Reports.tsx
Normal file
@@ -0,0 +1,400 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { choreLogsService, WeeklyChoreReport } from '../api/choreLogs';
|
||||
import {
|
||||
ChartBarIcon,
|
||||
TrophyIcon,
|
||||
CalendarIcon,
|
||||
UserGroupIcon,
|
||||
ArrowLeftIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon
|
||||
} from '@heroicons/react/24/outline';
|
||||
|
||||
const Reports: React.FC = () => {
|
||||
const { user, logout } = useAuth();
|
||||
const [report, setReport] = useState<WeeklyChoreReport | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [weeksAgo, setWeeksAgo] = useState(0);
|
||||
const [selectedUserId, setSelectedUserId] = useState<number | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadReport();
|
||||
}, [weeksAgo, selectedUserId]);
|
||||
|
||||
const loadReport = async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await choreLogsService.getWeeklyReport(
|
||||
selectedUserId || undefined,
|
||||
weeksAgo
|
||||
);
|
||||
setReport(data);
|
||||
} catch (error) {
|
||||
console.error('Failed to load report:', error);
|
||||
setError('Failed to load report. Please try again.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
});
|
||||
};
|
||||
|
||||
const getWeekLabel = () => {
|
||||
if (weeksAgo === 0) return 'This Week';
|
||||
if (weeksAgo === 1) return 'Last Week';
|
||||
return `${weeksAgo} Weeks Ago`;
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-center">
|
||||
<div className="inline-block animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
|
||||
<p className="mt-4 text-gray-600">Loading report...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
||||
<p className="text-red-800">{error}</p>
|
||||
<button
|
||||
onClick={loadReport}
|
||||
className="mt-2 text-red-600 hover:text-red-800 font-medium"
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* Header */}
|
||||
<div className="bg-white shadow">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex justify-between items-center py-6">
|
||||
<div className="flex items-center space-x-4">
|
||||
<Link
|
||||
to="/dashboard"
|
||||
className="text-gray-600 hover:text-gray-900"
|
||||
>
|
||||
<ArrowLeftIcon className="h-6 w-6" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">
|
||||
Weekly Reports
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
Family chore completion statistics
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4">
|
||||
<span className="text-sm text-gray-600">
|
||||
{user?.full_name || user?.username}
|
||||
</span>
|
||||
<button
|
||||
onClick={logout}
|
||||
className="text-sm text-gray-600 hover:text-gray-900"
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{/* Week Navigation */}
|
||||
<div className="bg-white rounded-lg shadow p-6 mb-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
onClick={() => setWeeksAgo(weeksAgo + 1)}
|
||||
className="flex items-center space-x-2 px-4 py-2 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors"
|
||||
>
|
||||
<ChevronLeftIcon className="h-5 w-5" />
|
||||
<span>Previous Week</span>
|
||||
</button>
|
||||
|
||||
<div className="text-center">
|
||||
<div className="flex items-center space-x-2 text-gray-900">
|
||||
<CalendarIcon className="h-5 w-5" />
|
||||
<span className="text-lg font-semibold">{getWeekLabel()}</span>
|
||||
</div>
|
||||
{report && (
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
{formatDate(report.start_date)} - {formatDate(report.end_date)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setWeeksAgo(Math.max(0, weeksAgo - 1))}
|
||||
disabled={weeksAgo === 0}
|
||||
className={`flex items-center space-x-2 px-4 py-2 rounded-lg transition-colors ${
|
||||
weeksAgo === 0
|
||||
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
|
||||
: 'bg-gray-100 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
<span>Next Week</span>
|
||||
<ChevronRightIcon className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{report && (
|
||||
<>
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-6">
|
||||
{/* Total Completions */}
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0">
|
||||
<ChartBarIcon className="h-8 w-8 text-blue-600" />
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<p className="text-sm font-medium text-gray-500">
|
||||
Total Completions
|
||||
</p>
|
||||
<p className="text-3xl font-bold text-gray-900">
|
||||
{report.total_completions}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Active Users */}
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0">
|
||||
<UserGroupIcon className="h-8 w-8 text-green-600" />
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<p className="text-sm font-medium text-gray-500">
|
||||
Active Members
|
||||
</p>
|
||||
<p className="text-3xl font-bold text-gray-900">
|
||||
{Object.keys(report.completions_by_user).length}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Different Chores */}
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0">
|
||||
<TrophyIcon className="h-8 w-8 text-yellow-600" />
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<p className="text-sm font-medium text-gray-500">
|
||||
Different Chores
|
||||
</p>
|
||||
<p className="text-3xl font-bold text-gray-900">
|
||||
{Object.keys(report.completions_by_chore).length}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Top Performers */}
|
||||
<div className="bg-white rounded-lg shadow p-6 mb-6">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4 flex items-center">
|
||||
<TrophyIcon className="h-6 w-6 mr-2 text-yellow-600" />
|
||||
Top Performers
|
||||
</h2>
|
||||
<div className="space-y-4">
|
||||
{report.top_performers.map((performer, index) => (
|
||||
<div
|
||||
key={performer.username}
|
||||
className="flex items-center justify-between p-4 bg-gray-50 rounded-lg"
|
||||
>
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className={`flex-shrink-0 w-10 h-10 rounded-full flex items-center justify-center font-bold text-white ${
|
||||
index === 0 ? 'bg-yellow-500' :
|
||||
index === 1 ? 'bg-gray-400' :
|
||||
index === 2 ? 'bg-orange-600' :
|
||||
'bg-blue-500'
|
||||
}`}>
|
||||
{index + 1}
|
||||
</div>
|
||||
{performer.avatar_url ? (
|
||||
<img
|
||||
src={`http://10.0.0.243:8000${performer.avatar_url}`}
|
||||
alt={performer.username}
|
||||
className="w-12 h-12 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-12 h-12 rounded-full bg-gradient-to-br from-blue-400 to-purple-500 flex items-center justify-center text-white font-bold text-lg">
|
||||
{performer.username.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<p className="font-semibold text-gray-900">
|
||||
{performer.username}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
{performer.count} {performer.count === 1 ? 'chore' : 'chores'} completed
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-2xl font-bold text-gray-900">
|
||||
{performer.count}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{report.top_performers.length === 0 && (
|
||||
<p className="text-gray-500 text-center py-8">
|
||||
No completions recorded this week
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Completions by Day */}
|
||||
<div className="bg-white rounded-lg shadow p-6 mb-6">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4">
|
||||
Completions by Day
|
||||
</h2>
|
||||
<div className="space-y-3">
|
||||
{['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'].map((day) => {
|
||||
const count = report.completions_by_day[day] || 0;
|
||||
const maxCount = Math.max(...Object.values(report.completions_by_day));
|
||||
const percentage = maxCount > 0 ? (count / maxCount) * 100 : 0;
|
||||
|
||||
return (
|
||||
<div key={day} className="flex items-center space-x-4">
|
||||
<div className="w-24 text-sm font-medium text-gray-700">
|
||||
{day}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="w-full bg-gray-200 rounded-full h-8">
|
||||
<div
|
||||
className="bg-blue-600 h-8 rounded-full flex items-center justify-end pr-3 transition-all"
|
||||
style={{ width: `${percentage}%` }}
|
||||
>
|
||||
{count > 0 && (
|
||||
<span className="text-white text-sm font-semibold">
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Completions by Chore */}
|
||||
<div className="bg-white rounded-lg shadow p-6 mb-6">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4">
|
||||
Completions by Chore
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{Object.entries(report.completions_by_chore)
|
||||
.sort(([, a], [, b]) => b - a)
|
||||
.map(([chore, count]) => (
|
||||
<div key={chore} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
||||
<span className="text-gray-900 font-medium">{chore}</span>
|
||||
<span className="text-blue-600 font-bold">{count}</span>
|
||||
</div>
|
||||
))}
|
||||
{Object.keys(report.completions_by_chore).length === 0 && (
|
||||
<p className="text-gray-500 text-center py-8 col-span-2">
|
||||
No chores completed this week
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recent Completions */}
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4">
|
||||
Recent Completions
|
||||
</h2>
|
||||
<div className="space-y-3">
|
||||
{report.recent_completions.map((completion) => (
|
||||
<div
|
||||
key={completion.id}
|
||||
className="flex items-center justify-between p-4 bg-gray-50 rounded-lg"
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
{completion.user_avatar ? (
|
||||
<img
|
||||
src={`http://10.0.0.243:8000${completion.user_avatar}`}
|
||||
alt={completion.user_name || 'User'}
|
||||
className="w-10 h-10 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-blue-400 to-purple-500 flex items-center justify-center text-white font-bold">
|
||||
{completion.user_name?.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">
|
||||
{completion.chore_title}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
by {completion.user_name}
|
||||
{completion.notes && (
|
||||
<span className="ml-2 italic">- "{completion.notes}"</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm text-gray-500">
|
||||
{new Date(completion.completed_at).toLocaleDateString()}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400">
|
||||
{new Date(completion.completed_at).toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{report.recent_completions.length === 0 && (
|
||||
<p className="text-gray-500 text-center py-8">
|
||||
No completions to show
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Reports;
|
||||
625
frontend/src/pages/Settings.tsx
Normal file
625
frontend/src/pages/Settings.tsx
Normal 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;
|
||||
59
frontend/src/pages/UserStatsPage.tsx
Normal file
59
frontend/src/pages/UserStatsPage.tsx
Normal 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;
|
||||
44
frontend/src/utils/avatarUtils.ts
Normal file
44
frontend/src/utils/avatarUtils.ts
Normal 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();
|
||||
}
|
||||
16
frontend/tailwind.config.js
Normal file
16
frontend/tailwind.config.js
Normal file
@@ -0,0 +1,16 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: '#3B82F6',
|
||||
secondary: '#8B5CF6',
|
||||
}
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
25
frontend/tsconfig.json
Normal file
25
frontend/tsconfig.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
10
frontend/tsconfig.node.json
Normal file
10
frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
14
frontend/vite.config.ts
Normal file
14
frontend/vite.config.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: 5173,
|
||||
watch: {
|
||||
usePolling: true
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user