feat: Implementar funcionalidades de Tarefas e Saúde
- Criadas APIs para Health (métricas de saúde) * Registrar peso, altura, % gordura, medidas * Histórico completo de medições * Estatísticas e resumo - Criadas APIs para Tasks (tarefas) * Criar, editar e deletar tarefas * Filtros por status e data * Estatísticas detalhadas * Prioridades (baixa, média, alta) - Frontend implementado: * Página Health.tsx - registro de métricas * Página Tasks.tsx - gerenciamento de tarefas * Página Progress.tsx - visualização de progresso * Dashboard integrado com estatísticas reais - Schemas e modelos atualizados - Todas as funcionalidades testadas e operacionais
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
|
||||
COPY . .
|
||||
|
||||
# Expose port
|
||||
EXPOSE 3000
|
||||
|
||||
# Start development server
|
||||
CMD ["npm", "start"]
|
||||
50
frontend/package.json
Normal file
50
frontend/package.json
Normal file
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"name": "vida180-frontend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@testing-library/jest-dom": "^5.17.0",
|
||||
"@testing-library/react": "^13.4.0",
|
||||
"@testing-library/user-event": "^13.5.0",
|
||||
"@types/jest": "^27.5.2",
|
||||
"@types/node": "^16.18.0",
|
||||
"@types/react": "^18.2.0",
|
||||
"@types/react-dom": "^18.2.0",
|
||||
"axios": "^1.6.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.20.0",
|
||||
"react-scripts": "5.0.1",
|
||||
"typescript": "^4.9.5",
|
||||
"web-vitals": "^2.1.4"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
"build": "react-scripts build",
|
||||
"test": "react-scripts test",
|
||||
"eject": "react-scripts eject"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
"react-app",
|
||||
"react-app/jest"
|
||||
]
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"tailwindcss": "^3.3.0",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"postcss": "^8.4.31"
|
||||
}
|
||||
}
|
||||
17
frontend/public/index.html
Normal file
17
frontend/public/index.html
Normal file
@@ -0,0 +1,17 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="pt-BR">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta
|
||||
name="description"
|
||||
content="Vida180 - Transforme sua vida em 180 graus"
|
||||
/>
|
||||
<title>Vida180 - Transforme sua vida</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>Você precisa habilitar JavaScript para rodar este app.</noscript>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
BIN
frontend/public/vida180_background.jpg
Normal file
BIN
frontend/public/vida180_background.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 16 MiB |
43
frontend/src/App.css
Normal file
43
frontend/src/App.css
Normal file
@@ -0,0 +1,43 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Poppins', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto',
|
||||
'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans',
|
||||
'Helvetica Neue', sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
.loading-screen {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
font-size: 4rem;
|
||||
animation: bounce 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.loading-screen p {
|
||||
margin-top: 1rem;
|
||||
font-size: 1.2rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@keyframes bounce {
|
||||
0%, 100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-20px);
|
||||
}
|
||||
}
|
||||
62
frontend/src/App.tsx
Normal file
62
frontend/src/App.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import React, { useState } from 'react';
|
||||
import { AuthProvider, useAuth } from './contexts/AuthContext';
|
||||
import Login from './pages/Login';
|
||||
import Register from './pages/Register';
|
||||
import Dashboard from './pages/Dashboard';
|
||||
import Habits from './pages/Habits';
|
||||
import Admin from './pages/Admin';
|
||||
import Tasks from './pages/Tasks';
|
||||
import Health from './pages/Health';
|
||||
import Progress from './pages/Progress';
|
||||
import './App.css';
|
||||
|
||||
type Page = 'dashboard' | 'habits' | 'admin' | 'tasks' | 'health' | 'progress';
|
||||
|
||||
const AppContent: React.FC = () => {
|
||||
const { user, loading } = useAuth();
|
||||
const [showRegister, setShowRegister] = useState(false);
|
||||
const [currentPage, setCurrentPage] = useState<Page>('dashboard');
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="loading-screen">
|
||||
<div className="loading-spinner">🚀</div>
|
||||
<p>Carregando Vida180...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (user) {
|
||||
switch (currentPage) {
|
||||
case 'habits':
|
||||
return <Habits onBack={() => setCurrentPage('dashboard')} />;
|
||||
case 'admin':
|
||||
return <Admin onBack={() => setCurrentPage('dashboard')} />;
|
||||
case 'tasks':
|
||||
return <Tasks onBack={() => setCurrentPage('dashboard')} />;
|
||||
case 'health':
|
||||
return <Health onBack={() => setCurrentPage('dashboard')} />;
|
||||
case 'progress':
|
||||
return <Progress onBack={() => setCurrentPage('dashboard')} />;
|
||||
case 'dashboard':
|
||||
default:
|
||||
return <Dashboard onNavigate={setCurrentPage} />;
|
||||
}
|
||||
}
|
||||
|
||||
return showRegister ? (
|
||||
<Register onSwitchToLogin={() => setShowRegister(false)} />
|
||||
) : (
|
||||
<Login onSwitchToRegister={() => setShowRegister(true)} />
|
||||
);
|
||||
};
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<AppContent />
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
BIN
frontend/src/background-internal.jpg
Normal file
BIN
frontend/src/background-internal.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 MiB |
132
frontend/src/components/DailyMessage.css
Normal file
132
frontend/src/components/DailyMessage.css
Normal file
@@ -0,0 +1,132 @@
|
||||
.daily-message {
|
||||
background: linear-gradient(135deg, rgba(102, 126, 234, 0.15) 0%, rgba(118, 75, 162, 0.15) 100%);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 2px solid rgba(102, 126, 234, 0.3);
|
||||
border-radius: 16px;
|
||||
padding: 1.5rem;
|
||||
margin: 1.5rem 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.daily-message::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent);
|
||||
transition: left 0.5s;
|
||||
}
|
||||
|
||||
.daily-message:hover::before {
|
||||
left: 100%;
|
||||
}
|
||||
|
||||
.daily-message:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 10px 30px rgba(102, 126, 234, 0.3);
|
||||
border-color: rgba(102, 126, 234, 0.6);
|
||||
}
|
||||
|
||||
.daily-message .icon {
|
||||
font-size: 2.5rem;
|
||||
flex-shrink: 0;
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { transform: scale(1); }
|
||||
50% { transform: scale(1.1); }
|
||||
}
|
||||
|
||||
.daily-message .message-text {
|
||||
flex: 1;
|
||||
color: #1a202c;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.5;
|
||||
text-align: left;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.daily-message .message-badge {
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 0.5rem;
|
||||
background: rgba(102, 126, 234, 0.2);
|
||||
color: #5b21b6;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
padding: 0.3rem 0.6rem;
|
||||
border-radius: 8px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
/* Tipos específicos de mensagem */
|
||||
.daily-message.streak_achievement {
|
||||
border-color: rgba(234, 179, 8, 0.5);
|
||||
background: linear-gradient(135deg, rgba(234, 179, 8, 0.1) 0%, rgba(245, 158, 11, 0.1) 100%);
|
||||
}
|
||||
|
||||
.daily-message.reminder {
|
||||
border-color: rgba(59, 130, 246, 0.5);
|
||||
background: linear-gradient(135deg, rgba(59, 130, 246, 0.1) 0%, rgba(37, 99, 235, 0.1) 100%);
|
||||
}
|
||||
|
||||
.daily-message.progress_milestone {
|
||||
border-color: rgba(16, 185, 129, 0.5);
|
||||
background: linear-gradient(135deg, rgba(16, 185, 129, 0.1) 0%, rgba(5, 150, 105, 0.1) 100%);
|
||||
}
|
||||
|
||||
.daily-message.recovery {
|
||||
border-color: rgba(139, 92, 246, 0.5);
|
||||
background: linear-gradient(135deg, rgba(139, 92, 246, 0.1) 0%, rgba(124, 58, 237, 0.1) 100%);
|
||||
}
|
||||
|
||||
/* Loading state */
|
||||
.daily-message.loading {
|
||||
justify-content: center;
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
font-size: 2rem;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Error state */
|
||||
.daily-message.error {
|
||||
border-color: rgba(239, 68, 68, 0.3);
|
||||
background: linear-gradient(135deg, rgba(239, 68, 68, 0.05) 0%, rgba(220, 38, 38, 0.05) 100%);
|
||||
}
|
||||
|
||||
/* Mobile */
|
||||
@media (max-width: 768px) {
|
||||
.daily-message {
|
||||
padding: 1rem;
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.daily-message .icon {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.daily-message .message-text {
|
||||
text-align: center;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
}
|
||||
103
frontend/src/components/DailyMessage.tsx
Normal file
103
frontend/src/components/DailyMessage.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import './DailyMessage.css';
|
||||
|
||||
interface Message {
|
||||
id: string | null;
|
||||
message_text: string;
|
||||
icon: string;
|
||||
message_type: string;
|
||||
priority: number;
|
||||
}
|
||||
|
||||
interface DailyMessageProps {
|
||||
userId: string;
|
||||
}
|
||||
|
||||
const DailyMessage: React.FC<DailyMessageProps> = ({ userId }) => {
|
||||
const [message, setMessage] = useState<Message | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchDailyMessage();
|
||||
}, [userId]);
|
||||
|
||||
const fetchDailyMessage = async () => {
|
||||
try {
|
||||
const backendUrl = `${window.location.protocol}//${window.location.host}`;
|
||||
|
||||
const response = await fetch(
|
||||
`${backendUrl}/api/v1/messages/daily?user_id=${userId}`
|
||||
);
|
||||
|
||||
if (!response.ok) throw new Error('Erro ao buscar mensagem');
|
||||
|
||||
const data = await response.json();
|
||||
setMessage(data.message);
|
||||
setLoading(false);
|
||||
} catch (err) {
|
||||
console.error('Erro ao carregar mensagem:', err);
|
||||
setError(true);
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClick = async () => {
|
||||
if (message?.id) {
|
||||
try {
|
||||
const backendUrl = `${window.location.protocol}//${window.location.host}`;
|
||||
|
||||
await fetch(
|
||||
`${backendUrl}/api/v1/messages/click/${message.id}?user_id=${userId}`,
|
||||
{ method: 'POST' }
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('Erro ao registrar click:', err);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="daily-message loading">
|
||||
<div className="loading-spinner">⏳</div>
|
||||
<p>Carregando mensagem...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !message) {
|
||||
return (
|
||||
<div className="daily-message">
|
||||
<span className="icon">💪</span>
|
||||
<p className="message-text">A transformação acontece um dia de cada vez!</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`daily-message ${message.message_type}`}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<span className="icon">{message.icon}</span>
|
||||
<p className="message-text">{message.message_text}</p>
|
||||
<div className="message-badge">{getMessageTypeBadge(message.message_type)}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const getMessageTypeBadge = (type: string): string => {
|
||||
const badges: { [key: string]: string } = {
|
||||
'streak_achievement': '🏆 Conquista',
|
||||
'reminder': '🔔 Lembrete',
|
||||
'progress_milestone': '📈 Progresso',
|
||||
'time_based': '⏰ Momento',
|
||||
'special_milestone': '⭐ Especial',
|
||||
'recovery': '💙 Retorno',
|
||||
'daily_motivation': '💪 Motivação'
|
||||
};
|
||||
return badges[type] || '✨ Mensagem';
|
||||
};
|
||||
|
||||
export default DailyMessage;
|
||||
115
frontend/src/contexts/AuthContext.tsx
Normal file
115
frontend/src/contexts/AuthContext.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import React, { createContext, useState, useContext, useEffect, ReactNode } from 'react';
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
email: string;
|
||||
full_name?: string;
|
||||
phone?: string;
|
||||
is_superadmin?: boolean;
|
||||
is_verified?: boolean;
|
||||
}
|
||||
|
||||
interface AuthContextType {
|
||||
user: User | null;
|
||||
token: string | null;
|
||||
login: (email: string, password: string) => Promise<void>;
|
||||
register: (email: string, username: string, password: string, fullName: string, phone: string) => Promise<void>;
|
||||
logout: () => void;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [token, setToken] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Usar a URL correta do backend
|
||||
const backendUrl = window.location.hostname === 'localhost'
|
||||
? 'http://localhost:8000'
|
||||
: `${window.location.protocol}//${window.location.hostname}`;
|
||||
|
||||
useEffect(() => {
|
||||
const storedToken = localStorage.getItem('token');
|
||||
const storedUser = localStorage.getItem('user');
|
||||
|
||||
if (storedToken && storedUser) {
|
||||
setToken(storedToken);
|
||||
setUser(JSON.parse(storedUser));
|
||||
}
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
const login = async (email: string, password: string) => {
|
||||
const response = await fetch(`${backendUrl}/api/v1/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || 'Login failed');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setToken(data.access_token);
|
||||
setUser(data.user);
|
||||
localStorage.setItem('token', data.access_token);
|
||||
localStorage.setItem('user', JSON.stringify(data.user));
|
||||
};
|
||||
|
||||
const register = async (
|
||||
email: string,
|
||||
username: string,
|
||||
password: string,
|
||||
fullName: string,
|
||||
phone: string
|
||||
) => {
|
||||
const response = await fetch(`${backendUrl}/api/v1/auth/register`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
email,
|
||||
username,
|
||||
password,
|
||||
full_name: fullName,
|
||||
phone
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || 'Registration failed');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setToken(data.access_token);
|
||||
setUser(data.user);
|
||||
localStorage.setItem('token', data.access_token);
|
||||
localStorage.setItem('user', JSON.stringify(data.user));
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
setUser(null);
|
||||
setToken(null);
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user');
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, token, login, register, logout, loading }}>
|
||||
{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;
|
||||
};
|
||||
25
frontend/src/index.css
Normal file
25
frontend/src/index.css
Normal file
@@ -0,0 +1,25 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;500;600;700;800;900&display=swap');
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: 'Poppins', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto',
|
||||
'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
||||
monospace;
|
||||
}
|
||||
|
||||
#root {
|
||||
min-height: 100vh;
|
||||
}
|
||||
14
frontend/src/index.tsx
Normal file
14
frontend/src/index.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import './index.css';
|
||||
import App from './App';
|
||||
|
||||
const root = ReactDOM.createRoot(
|
||||
document.getElementById('root') as HTMLElement
|
||||
);
|
||||
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
283
frontend/src/pages/Admin.css
Normal file
283
frontend/src/pages/Admin.css
Normal file
@@ -0,0 +1,283 @@
|
||||
.admin-page {
|
||||
min-height: 100vh;
|
||||
background: url('../background-internal.jpg') center/cover no-repeat fixed;
|
||||
position: relative;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.admin-page::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(255, 255, 255, 0.4);
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.admin-header {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto 2rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
padding: 1.5rem;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 5px 20px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.admin-header h1 {
|
||||
font-size: 2rem;
|
||||
color: #8b5cf6;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.admin-stats {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto 2rem;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.stat-box {
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
padding: 1.5rem;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
box-shadow: 0 5px 20px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
|
||||
.stat-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 2rem;
|
||||
font-weight: 800;
|
||||
color: #8b5cf6;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.85rem;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.users-section {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
padding: 2rem;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 5px 20px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.users-section h2 {
|
||||
margin: 0 0 1.5rem 0;
|
||||
color: #1a202c;
|
||||
}
|
||||
|
||||
.users-table {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
thead {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
th {
|
||||
padding: 1rem;
|
||||
text-align: left;
|
||||
font-weight: 700;
|
||||
color: #475569;
|
||||
border-bottom: 2px solid #e2e8f0;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
tr.inactive {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.badge-admin {
|
||||
display: inline-block;
|
||||
margin-left: 0.5rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
background: #8b5cf6;
|
||||
color: white;
|
||||
border-radius: 4px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 12px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status-badge.active {
|
||||
background: #d1fae5;
|
||||
color: #065f46;
|
||||
}
|
||||
|
||||
.status-badge.inactive {
|
||||
background: #fee;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.actions button {
|
||||
padding: 0.5rem;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 1.2rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.btn-toggle {
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
.btn-toggle:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.btn-password {
|
||||
background: #dbeafe;
|
||||
}
|
||||
|
||||
.btn-password:hover {
|
||||
background: #3b82f6;
|
||||
}
|
||||
|
||||
.btn-delete {
|
||||
background: #fee;
|
||||
}
|
||||
|
||||
.btn-delete:hover {
|
||||
background: #ef4444;
|
||||
}
|
||||
|
||||
/* Modal */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: white;
|
||||
padding: 2rem;
|
||||
border-radius: 20px;
|
||||
max-width: 500px;
|
||||
width: 100%;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.modal-content h2 {
|
||||
margin: 0 0 1rem 0;
|
||||
color: #8b5cf6;
|
||||
}
|
||||
|
||||
.modal-content p {
|
||||
margin-bottom: 1.5rem;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-weight: 600;
|
||||
color: #2d3748;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.form-group input {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
border: 2px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
font-family: 'Poppins', sans-serif;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.btn-cancel {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: #e5e7eb;
|
||||
color: #374151;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-confirm {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
font-size: 1.5rem;
|
||||
padding: 3rem;
|
||||
}
|
||||
|
||||
.btn-edit {
|
||||
background: #fef3c7;
|
||||
}
|
||||
|
||||
.btn-edit:hover {
|
||||
background: #fbbf24;
|
||||
}
|
||||
382
frontend/src/pages/Admin.tsx
Normal file
382
frontend/src/pages/Admin.tsx
Normal file
@@ -0,0 +1,382 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import './Admin.css';
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
username: string;
|
||||
full_name?: string;
|
||||
is_active: boolean;
|
||||
is_verified: boolean;
|
||||
is_superadmin: boolean;
|
||||
created_at: string;
|
||||
last_login_at?: string;
|
||||
}
|
||||
|
||||
interface Stats {
|
||||
total_users: number;
|
||||
active_users: number;
|
||||
inactive_users: number;
|
||||
total_habits: number;
|
||||
total_completions: number;
|
||||
}
|
||||
|
||||
const Admin: React.FC<{ onBack: () => void }> = ({ onBack }) => {
|
||||
const { token } = useAuth();
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [stats, setStats] = useState<Stats | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedUser, setSelectedUser] = useState<User | null>(null);
|
||||
const [modalType, setModalType] = useState<'password' | 'edit' | null>(null);
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [editData, setEditData] = useState({
|
||||
email: '',
|
||||
username: '',
|
||||
full_name: ''
|
||||
});
|
||||
|
||||
const backendUrl = `${window.location.protocol}//${window.location.host}`;
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
const statsRes = await fetch(`${backendUrl}/api/v1/admin/stats`, {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
if (statsRes.ok) {
|
||||
setStats(await statsRes.json());
|
||||
}
|
||||
|
||||
const usersRes = await fetch(`${backendUrl}/api/v1/admin/users`, {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
if (usersRes.ok) {
|
||||
setUsers(await usersRes.json());
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erro ao carregar dados:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openEditModal = (user: User) => {
|
||||
setSelectedUser(user);
|
||||
setEditData({
|
||||
email: user.email,
|
||||
username: user.username,
|
||||
full_name: user.full_name || ''
|
||||
});
|
||||
setModalType('edit');
|
||||
};
|
||||
|
||||
const openPasswordModal = (user: User) => {
|
||||
setSelectedUser(user);
|
||||
setNewPassword('');
|
||||
setModalType('password');
|
||||
};
|
||||
|
||||
const updateUser = async () => {
|
||||
if (!selectedUser) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${backendUrl}/api/v1/admin/users/${selectedUser.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(editData)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
alert('✅ Usuário atualizado!');
|
||||
setModalType(null);
|
||||
setSelectedUser(null);
|
||||
loadData();
|
||||
} else {
|
||||
const error = await response.json();
|
||||
alert('❌ Erro: ' + (error.detail || 'Erro ao atualizar'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erro ao atualizar usuário:', error);
|
||||
alert('❌ Erro ao atualizar usuário');
|
||||
}
|
||||
};
|
||||
|
||||
const toggleUserActive = async (userId: string, currentStatus: boolean) => {
|
||||
try {
|
||||
const response = await fetch(`${backendUrl}/api/v1/admin/users/${userId}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ is_active: !currentStatus })
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
loadData();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erro ao atualizar usuário:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteUser = async (userId: string) => {
|
||||
if (!confirm('Tem certeza que deseja desativar este usuário?')) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${backendUrl}/api/v1/admin/users/${userId}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
loadData();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erro ao deletar usuário:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const resetPassword = async () => {
|
||||
if (!selectedUser) return;
|
||||
|
||||
if (!newPassword || newPassword.length < 6) {
|
||||
alert('Senha deve ter no mínimo 6 caracteres!');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${backendUrl}/api/v1/admin/users/${selectedUser.id}/reset-password`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ new_password: newPassword })
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
alert('✅ Senha alterada com sucesso!');
|
||||
setModalType(null);
|
||||
setSelectedUser(null);
|
||||
setNewPassword('');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erro ao resetar senha:', error);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="admin-page"><div className="loading">⏳ Carregando...</div></div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="admin-page">
|
||||
<div className="admin-header">
|
||||
<button onClick={onBack} className="back-button">← Voltar</button>
|
||||
<h1>👨💼 Painel Administrativo</h1>
|
||||
</div>
|
||||
|
||||
{stats && (
|
||||
<div className="admin-stats">
|
||||
<div className="stat-box">
|
||||
<div className="stat-icon">👥</div>
|
||||
<div className="stat-info">
|
||||
<span className="stat-value">{stats.total_users}</span>
|
||||
<span className="stat-label">Total Usuários</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="stat-box">
|
||||
<div className="stat-icon">✅</div>
|
||||
<div className="stat-info">
|
||||
<span className="stat-value">{stats.active_users}</span>
|
||||
<span className="stat-label">Ativos</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="stat-box">
|
||||
<div className="stat-icon">❌</div>
|
||||
<div className="stat-info">
|
||||
<span className="stat-value">{stats.inactive_users}</span>
|
||||
<span className="stat-label">Inativos</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="stat-box">
|
||||
<div className="stat-icon">🎯</div>
|
||||
<div className="stat-info">
|
||||
<span className="stat-value">{stats.total_habits}</span>
|
||||
<span className="stat-label">Hábitos</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="stat-box">
|
||||
<div className="stat-icon">📊</div>
|
||||
<div className="stat-info">
|
||||
<span className="stat-value">{stats.total_completions}</span>
|
||||
<span className="stat-label">Completions</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="users-section">
|
||||
<h2>👥 Usuários Cadastrados</h2>
|
||||
<div className="users-table">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Email</th>
|
||||
<th>Username</th>
|
||||
<th>Nome</th>
|
||||
<th>Status</th>
|
||||
<th>Cadastro</th>
|
||||
<th>Ações</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map(user => (
|
||||
<tr key={user.id} className={!user.is_active ? 'inactive' : ''}>
|
||||
<td>{user.email}</td>
|
||||
<td>
|
||||
{user.username}
|
||||
{user.is_superadmin && <span className="badge-admin">ADMIN</span>}
|
||||
</td>
|
||||
<td>{user.full_name || '-'}</td>
|
||||
<td>
|
||||
<span className={`status-badge ${user.is_active ? 'active' : 'inactive'}`}>
|
||||
{user.is_active ? 'Ativo' : 'Inativo'}
|
||||
</span>
|
||||
</td>
|
||||
<td>{new Date(user.created_at).toLocaleDateString('pt-BR')}</td>
|
||||
<td className="actions">
|
||||
{!user.is_superadmin && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => openEditModal(user)}
|
||||
className="btn-edit"
|
||||
title="Editar Usuário"
|
||||
>
|
||||
✏️
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => toggleUserActive(user.id, user.is_active)}
|
||||
className="btn-toggle"
|
||||
title={user.is_active ? 'Desativar' : 'Ativar'}
|
||||
>
|
||||
{user.is_active ? '🔴' : '🟢'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => openPasswordModal(user)}
|
||||
className="btn-password"
|
||||
title="Resetar Senha"
|
||||
>
|
||||
🔑
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => deleteUser(user.id)}
|
||||
className="btn-delete"
|
||||
title="Deletar"
|
||||
>
|
||||
🗑️
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modal de Editar Usuário */}
|
||||
{modalType === 'edit' && selectedUser && (
|
||||
<div className="modal-overlay" onClick={() => setModalType(null)}>
|
||||
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
||||
<h2>✏️ Editar Usuário</h2>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Email</label>
|
||||
<input
|
||||
type="email"
|
||||
value={editData.email}
|
||||
onChange={(e) => setEditData({...editData, email: e.target.value})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Username</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editData.username}
|
||||
onChange={(e) => setEditData({...editData, username: e.target.value})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Nome Completo</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editData.full_name}
|
||||
onChange={(e) => setEditData({...editData, full_name: e.target.value})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="modal-actions">
|
||||
<button onClick={() => setModalType(null)} className="btn-cancel">
|
||||
Cancelar
|
||||
</button>
|
||||
<button onClick={updateUser} className="btn-confirm">
|
||||
Salvar Alterações
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Modal de Reset de Senha */}
|
||||
{modalType === 'password' && selectedUser && (
|
||||
<div className="modal-overlay" onClick={() => setModalType(null)}>
|
||||
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
||||
<h2>🔑 Resetar Senha</h2>
|
||||
<p>Usuário: <strong>{selectedUser.username}</strong></p>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Nova Senha</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
placeholder="Mínimo 6 caracteres"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="modal-actions">
|
||||
<button onClick={() => setModalType(null)} className="btn-cancel">
|
||||
Cancelar
|
||||
</button>
|
||||
<button onClick={resetPassword} className="btn-confirm">
|
||||
Resetar Senha
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Admin;
|
||||
190
frontend/src/pages/Auth.css
Normal file
190
frontend/src/pages/Auth.css
Normal file
@@ -0,0 +1,190 @@
|
||||
.auth-container {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: url('../vida180_background.jpg') center/cover no-repeat fixed;
|
||||
padding: 1rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.auth-container::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(255, 255, 255, 0.3); /* REDUZI DE 0.85 PARA 0.3 */
|
||||
}
|
||||
|
||||
.auth-box {
|
||||
background: rgba(255, 255, 255, 0.95); /* MAIS OPACO PARA LEITURA */
|
||||
backdrop-filter: blur(12px);
|
||||
border-radius: 20px;
|
||||
padding: 2.5rem;
|
||||
max-width: 450px;
|
||||
width: 100%;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.25);
|
||||
animation: fadeInUp 0.5s ease-out;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
border: 3px solid #10b981;
|
||||
}
|
||||
|
||||
.auth-header {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.auth-header h1 {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 800;
|
||||
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
margin: 0;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.auth-header p {
|
||||
color: #4a5568;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.auth-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
font-weight: 600;
|
||||
color: #2d3748;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.form-group input {
|
||||
padding: 0.875rem 1rem;
|
||||
border: 2px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
font-size: 1rem;
|
||||
transition: all 0.3s ease;
|
||||
font-family: 'Poppins', sans-serif;
|
||||
}
|
||||
|
||||
.form-group input:focus {
|
||||
outline: none;
|
||||
border-color: #10b981;
|
||||
box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.1);
|
||||
}
|
||||
|
||||
.form-group input:disabled {
|
||||
background: #f7fafc;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
background: linear-gradient(135deg, #fee 0%, #fdd 100%);
|
||||
border: 2px solid #ef4444;
|
||||
border-radius: 12px;
|
||||
padding: 0.875rem;
|
||||
color: #dc2626;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.auth-button {
|
||||
padding: 1rem;
|
||||
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
margin-top: 0.5rem;
|
||||
font-family: 'Poppins', sans-serif;
|
||||
}
|
||||
|
||||
.auth-button:hover:not(:disabled) {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 10px 25px rgba(16, 185, 129, 0.4);
|
||||
}
|
||||
|
||||
.auth-button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.auth-footer {
|
||||
text-align: center;
|
||||
margin-top: 1.5rem;
|
||||
padding-top: 1.5rem;
|
||||
border-top: 2px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.auth-footer p {
|
||||
color: #64748b;
|
||||
font-size: 0.9rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.link-button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #10b981;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
font-size: 0.9rem;
|
||||
font-family: 'Poppins', sans-serif;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.link-button:hover {
|
||||
color: #059669;
|
||||
}
|
||||
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.auth-box {
|
||||
padding: 2rem 1.5rem;
|
||||
}
|
||||
|
||||
.auth-header h1 {
|
||||
font-size: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
.verification-notice {
|
||||
margin-top: 1rem;
|
||||
padding: 1rem;
|
||||
background: #dbeafe;
|
||||
border: 2px solid #3b82f6;
|
||||
border-radius: 12px;
|
||||
text-align: center;
|
||||
font-size: 0.9rem;
|
||||
color: #1e40af;
|
||||
font-weight: 600;
|
||||
}
|
||||
238
frontend/src/pages/Dashboard.css
Normal file
238
frontend/src/pages/Dashboard.css
Normal file
@@ -0,0 +1,238 @@
|
||||
.dashboard {
|
||||
min-height: 100vh;
|
||||
background: url('../background-internal.jpg') center/cover no-repeat fixed;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.dashboard::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(255, 255, 255, 0.4); /* REDUZI DE 0.85 PARA 0.4 */
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.dashboard-header {
|
||||
background: rgba(255, 255, 255, 0.95); /* MAIS OPACO */
|
||||
backdrop-filter: blur(10px);
|
||||
border-bottom: 3px solid #10b981;
|
||||
padding: 1.5rem 0;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 2rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.dashboard-header h1 {
|
||||
font-size: 2rem;
|
||||
font-weight: 800;
|
||||
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.user-menu {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-weight: 600;
|
||||
color: #2d3748;
|
||||
}
|
||||
|
||||
.logout-button {
|
||||
padding: 0.5rem 1.5rem;
|
||||
background: linear-gradient(135deg, #f97316 0%, #ea580c 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.logout-button:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 5px 15px rgba(249, 115, 22, 0.4);
|
||||
}
|
||||
|
||||
.dashboard-main {
|
||||
padding: 2rem 0;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.dashboard-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 2rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.welcome-section {
|
||||
background: rgba(255, 255, 255, 0.92); /* MAIS OPACO */
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 20px;
|
||||
padding: 2rem;
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.15);
|
||||
border: 2px solid #10b981;
|
||||
}
|
||||
|
||||
.welcome-section h2 {
|
||||
margin: 0 0 1rem 0;
|
||||
color: #059669;
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: rgba(255, 255, 255, 0.92); /* MAIS OPACO */
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 16px;
|
||||
padding: 1.5rem;
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
box-shadow: 0 5px 20px rgba(0, 0, 0, 0.12);
|
||||
transition: all 0.3s ease;
|
||||
border: 2px solid transparent;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 10px 30px rgba(16, 185, 129, 0.3);
|
||||
border-color: #10b981;
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
font-size: 2.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.stat-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.stat-content h3 {
|
||||
margin: 0;
|
||||
font-size: 0.9rem;
|
||||
color: #64748b;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 2rem;
|
||||
font-weight: 800;
|
||||
color: #059669;
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.85rem;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.main-areas {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.area-card {
|
||||
background: rgba(255, 255, 255, 0.92); /* MAIS OPACO */
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 16px;
|
||||
padding: 2rem;
|
||||
box-shadow: 0 5px 20px rgba(0, 0, 0, 0.12);
|
||||
transition: all 0.3s ease;
|
||||
border: 2px solid transparent;
|
||||
}
|
||||
|
||||
.area-card:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 10px 30px rgba(16, 185, 129, 0.3);
|
||||
border-color: #10b981;
|
||||
}
|
||||
|
||||
.area-card h3 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-size: 1.5rem;
|
||||
color: #059669;
|
||||
}
|
||||
|
||||
.area-card p {
|
||||
color: #64748b;
|
||||
margin: 0 0 1.5rem 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.area-button {
|
||||
width: 100%;
|
||||
padding: 0.875rem;
|
||||
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.area-button:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 5px 15px rgba(16, 185, 129, 0.4);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.header-content {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stats-grid,
|
||||
.main-areas {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.dashboard-container {
|
||||
padding: 0 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.admin-button {
|
||||
padding: 0.5rem 1.5rem;
|
||||
background: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.admin-button:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 5px 15px rgba(139, 92, 246, 0.4);
|
||||
}
|
||||
213
frontend/src/pages/Dashboard.tsx
Normal file
213
frontend/src/pages/Dashboard.tsx
Normal file
@@ -0,0 +1,213 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import DailyMessage from '../components/DailyMessage';
|
||||
import './Dashboard.css';
|
||||
|
||||
type Page = 'dashboard' | 'habits' | 'admin' | 'tasks' | 'health' | 'progress';
|
||||
|
||||
interface DashboardProps {
|
||||
onNavigate: (page: Page) => void;
|
||||
}
|
||||
|
||||
const Dashboard: React.FC<DashboardProps> = ({ onNavigate }) => {
|
||||
const { user, logout, token } = useAuth();
|
||||
const [stats, setStats] = useState({
|
||||
totalHabits: 0,
|
||||
completedToday: 0,
|
||||
pendingToday: 0
|
||||
});
|
||||
const [taskStats, setTaskStats] = useState({
|
||||
today_tasks: 0,
|
||||
today_completed: 0
|
||||
});
|
||||
const [healthStats, setHealthStats] = useState({
|
||||
current_weight: null as number | null
|
||||
});
|
||||
|
||||
const backendUrl = `${window.location.protocol}//${window.location.host}`;
|
||||
|
||||
useEffect(() => {
|
||||
loadStats();
|
||||
loadTaskStats();
|
||||
loadHealthStats();
|
||||
}, []);
|
||||
|
||||
const loadStats = async () => {
|
||||
try {
|
||||
const response = await fetch(`${backendUrl}/api/v1/habits/stats`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setStats({
|
||||
totalHabits: data.total_habits,
|
||||
completedToday: data.completed_today,
|
||||
pendingToday: data.pending_today
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erro ao carregar stats:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const loadTaskStats = async () => {
|
||||
try {
|
||||
const response = await fetch(`${backendUrl}/api/v1/tasks/stats/summary`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setTaskStats({
|
||||
today_tasks: data.today_tasks,
|
||||
today_completed: data.today_completed
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erro ao carregar stats de tarefas:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const loadHealthStats = async () => {
|
||||
try {
|
||||
const response = await fetch(`${backendUrl}/api/v1/health/stats/summary`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setHealthStats({
|
||||
current_weight: data.current_weight
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erro ao carregar stats de saúde:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="dashboard">
|
||||
<header className="dashboard-header">
|
||||
<div className="header-content">
|
||||
<h1>🚀 VIDA180</h1>
|
||||
<div className="user-menu">
|
||||
<span className="user-name">Olá, {user?.username}!</span>
|
||||
|
||||
{/* BOTÃO ADMIN - SÓ APARECE PARA SUPERADMIN */}
|
||||
{user?.is_superadmin && (
|
||||
<button onClick={() => onNavigate('admin')} className="admin-button">
|
||||
👨💼 Painel Admin
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button onClick={logout} className="logout-button">
|
||||
Sair
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="dashboard-main">
|
||||
<div className="dashboard-container">
|
||||
<section className="welcome-section">
|
||||
<h2>Bem-vindo de volta! 💪</h2>
|
||||
<DailyMessage userId={user?.id || 'demo-user'} />
|
||||
</section>
|
||||
|
||||
<section className="stats-grid">
|
||||
<div className="stat-card">
|
||||
<div className="stat-icon">🔥</div>
|
||||
<div className="stat-content">
|
||||
<h3>Sequência</h3>
|
||||
<p className="stat-value">7 dias</p>
|
||||
<span className="stat-label">Continue assim!</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="stat-card">
|
||||
<div className="stat-icon">✅</div>
|
||||
<div className="stat-content">
|
||||
<h3>Tarefas Hoje</h3>
|
||||
<p className="stat-value">{taskStats.today_completed}/{taskStats.today_tasks}</p>
|
||||
<span className="stat-label">
|
||||
{taskStats.today_tasks - taskStats.today_completed > 0
|
||||
? `Faltam ${taskStats.today_tasks - taskStats.today_completed}`
|
||||
: 'Tudo completo! 🎉'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="stat-card">
|
||||
<div className="stat-icon">🎯</div>
|
||||
<div className="stat-content">
|
||||
<h3>Hábitos</h3>
|
||||
<p className="stat-value">{stats.completedToday}/{stats.totalHabits}</p>
|
||||
<span className="stat-label">
|
||||
{stats.pendingToday > 0
|
||||
? `Faltam ${stats.pendingToday}`
|
||||
: 'Todos completos! 🎉'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="stat-card">
|
||||
<div className="stat-icon">📊</div>
|
||||
<div className="stat-content">
|
||||
<h3>Peso Atual</h3>
|
||||
<p className="stat-value">
|
||||
{healthStats.current_weight ? `${healthStats.current_weight} kg` : '-- kg'}
|
||||
</p>
|
||||
<span className="stat-label">
|
||||
{healthStats.current_weight ? 'Continue assim!' : 'Registre hoje'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="main-areas">
|
||||
<div className="area-card">
|
||||
<h3>📝 Tarefas</h3>
|
||||
<p>Organize seu dia e conquiste seus objetivos</p>
|
||||
<button className="area-button" onClick={() => onNavigate('tasks')}>
|
||||
Ver Tarefas
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="area-card">
|
||||
<h3>🎯 Hábitos</h3>
|
||||
<p>Construa rotinas poderosas para transformação</p>
|
||||
<button className="area-button" onClick={() => onNavigate('habits')}>
|
||||
Ver Hábitos
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="area-card">
|
||||
<h3>💪 Saúde</h3>
|
||||
<p>Acompanhe peso, medidas e evolução física</p>
|
||||
<button className="area-button" onClick={() => onNavigate('health')}>
|
||||
Ver Métricas
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="area-card">
|
||||
<h3>📈 Progresso</h3>
|
||||
<p>Visualize sua jornada de transformação</p>
|
||||
<button className="area-button" onClick={() => onNavigate('progress')}>
|
||||
Ver Gráficos
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Dashboard;
|
||||
293
frontend/src/pages/Habits.css
Normal file
293
frontend/src/pages/Habits.css
Normal file
@@ -0,0 +1,293 @@
|
||||
.habits-page {
|
||||
min-height: 100vh;
|
||||
background: url('../background-internal.jpg') center/cover no-repeat fixed;
|
||||
position: relative;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.habits-page::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(255, 255, 255, 0.4);
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.habits-header {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
max-width: 1000px;
|
||||
margin: 0 auto 2rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
padding: 1.5rem;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 5px 20px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.habits-header h1 {
|
||||
font-size: 2rem;
|
||||
color: #059669;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.back-button {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: #6b7280;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.back-button:hover {
|
||||
background: #4b5563;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.add-button {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.add-button:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 5px 15px rgba(16, 185, 129, 0.4);
|
||||
}
|
||||
|
||||
.habits-stats {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
max-width: 1000px;
|
||||
margin: 0 auto 2rem;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.habits-stats .stat {
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
padding: 1.5rem;
|
||||
border-radius: 12px;
|
||||
text-align: center;
|
||||
box-shadow: 0 5px 20px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
display: block;
|
||||
font-size: 2.5rem;
|
||||
font-weight: 800;
|
||||
color: #059669;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
display: block;
|
||||
font-size: 0.9rem;
|
||||
color: #64748b;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.habits-list {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.habit-card {
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
padding: 1.5rem;
|
||||
border-radius: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
box-shadow: 0 5px 20px rgba(0, 0, 0, 0.1);
|
||||
transition: all 0.3s ease;
|
||||
border: 2px solid transparent;
|
||||
}
|
||||
|
||||
.habit-card:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 10px 30px rgba(16, 185, 129, 0.2);
|
||||
border-color: #10b981;
|
||||
}
|
||||
|
||||
.habit-checkbox input[type="checkbox"] {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
cursor: pointer;
|
||||
accent-color: #10b981;
|
||||
}
|
||||
|
||||
.habit-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.habit-content h3 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
color: #1a202c;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.habit-content p {
|
||||
margin: 0 0 0.5rem 0;
|
||||
color: #64748b;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.habit-frequency {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.75rem;
|
||||
background: #e0f2fe;
|
||||
color: #0369a1;
|
||||
border-radius: 12px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.delete-button {
|
||||
padding: 0.5rem 1rem;
|
||||
background: #fee;
|
||||
border: 2px solid #ef4444;
|
||||
border-radius: 8px;
|
||||
font-size: 1.25rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.delete-button:hover {
|
||||
background: #ef4444;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
padding: 3rem;
|
||||
border-radius: 16px;
|
||||
text-align: center;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
margin: 0.5rem 0;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
font-size: 1.5rem;
|
||||
color: #059669;
|
||||
padding: 3rem;
|
||||
}
|
||||
|
||||
/* Modal */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: white;
|
||||
padding: 2rem;
|
||||
border-radius: 20px;
|
||||
max-width: 500px;
|
||||
width: 100%;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.modal-content h2 {
|
||||
margin: 0 0 1.5rem 0;
|
||||
color: #059669;
|
||||
}
|
||||
|
||||
.modal-content .form-group {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.modal-content label {
|
||||
display: block;
|
||||
font-weight: 600;
|
||||
color: #2d3748;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.modal-content input,
|
||||
.modal-content textarea,
|
||||
.modal-content select {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
border: 2px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
font-family: 'Poppins', sans-serif;
|
||||
}
|
||||
|
||||
.modal-content input:focus,
|
||||
.modal-content textarea:focus,
|
||||
.modal-content select:focus {
|
||||
outline: none;
|
||||
border-color: #10b981;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.cancel-button {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: #e5e7eb;
|
||||
color: #374151;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.create-button {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.habits-header {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.habits-stats {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
265
frontend/src/pages/Habits.tsx
Normal file
265
frontend/src/pages/Habits.tsx
Normal file
@@ -0,0 +1,265 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import './Habits.css';
|
||||
|
||||
interface Habit {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
frequency_type: string;
|
||||
target_count: number;
|
||||
is_active: boolean;
|
||||
start_date: string;
|
||||
}
|
||||
|
||||
const Habits: React.FC<{ onBack: () => void }> = ({ onBack }) => {
|
||||
const { token, user } = useAuth();
|
||||
const [habits, setHabits] = useState<Habit[]>([]);
|
||||
const [completedToday, setCompletedToday] = useState<Set<string>>(new Set());
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [newHabit, setNewHabit] = useState({
|
||||
name: '',
|
||||
description: '',
|
||||
frequency_type: 'daily',
|
||||
target_count: 1
|
||||
});
|
||||
|
||||
const backendUrl = `${window.location.protocol}//${window.location.host}`;
|
||||
|
||||
useEffect(() => {
|
||||
loadHabits();
|
||||
}, []);
|
||||
|
||||
const loadHabits = async () => {
|
||||
try {
|
||||
const response = await fetch(`${backendUrl}/api/v1/habits/`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setHabits(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erro ao carregar hábitos:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const createHabit = async () => {
|
||||
if (!newHabit.name.trim()) {
|
||||
alert('Nome do hábito é obrigatório!');
|
||||
return;
|
||||
}
|
||||
|
||||
setCreating(true);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${backendUrl}/api/v1/habits/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify(newHabit)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setNewHabit({ name: '', description: '', frequency_type: 'daily', target_count: 1 });
|
||||
setShowModal(false);
|
||||
await loadHabits();
|
||||
alert('✅ Hábito criado com sucesso!');
|
||||
} else {
|
||||
const error = await response.json();
|
||||
alert('❌ Erro: ' + (error.detail || 'Erro ao criar hábito'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erro ao criar hábito:', error);
|
||||
alert('❌ Erro ao criar hábito');
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleComplete = async (habitId: string) => {
|
||||
const isCompleted = completedToday.has(habitId);
|
||||
|
||||
try {
|
||||
const url = `${backendUrl}/api/v1/habits/${habitId}/complete`;
|
||||
const response = await fetch(url, {
|
||||
method: isCompleted ? 'DELETE' : 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const newCompleted = new Set(completedToday);
|
||||
if (isCompleted) {
|
||||
newCompleted.delete(habitId);
|
||||
} else {
|
||||
newCompleted.add(habitId);
|
||||
}
|
||||
setCompletedToday(newCompleted);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erro ao marcar hábito:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteHabit = async (habitId: string) => {
|
||||
if (!confirm('Tem certeza que deseja deletar este hábito?')) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${backendUrl}/api/v1/habits/${habitId}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
loadHabits();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erro ao deletar hábito:', error);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="habits-page">
|
||||
<div className="loading">⏳ Carregando hábitos...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="habits-page">
|
||||
<div className="habits-header">
|
||||
<button onClick={onBack} className="back-button">← Voltar</button>
|
||||
<h1>🎯 Meus Hábitos</h1>
|
||||
<button onClick={() => setShowModal(true)} className="add-button">
|
||||
+ Novo Hábito
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="habits-stats">
|
||||
<div className="stat">
|
||||
<span className="stat-value">{habits.length}</span>
|
||||
<span className="stat-label">Total</span>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<span className="stat-value">{completedToday.size}</span>
|
||||
<span className="stat-label">Completos Hoje</span>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<span className="stat-value">{habits.length - completedToday.size}</span>
|
||||
<span className="stat-label">Pendentes</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="habits-list">
|
||||
{habits.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<p>📝 Nenhum hábito criado ainda.</p>
|
||||
<p>Comece criando seu primeiro hábito!</p>
|
||||
</div>
|
||||
) : (
|
||||
habits.map(habit => (
|
||||
<div key={habit.id} className="habit-card">
|
||||
<div className="habit-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={completedToday.has(habit.id)}
|
||||
onChange={() => toggleComplete(habit.id)}
|
||||
/>
|
||||
</div>
|
||||
<div className="habit-content">
|
||||
<h3>{habit.name}</h3>
|
||||
{habit.description && <p>{habit.description}</p>}
|
||||
<span className="habit-frequency">{habit.frequency_type}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => deleteHabit(habit.id)}
|
||||
className="delete-button"
|
||||
title="Deletar hábito"
|
||||
>
|
||||
🗑️
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Modal de Criar Hábito */}
|
||||
{showModal && (
|
||||
<div className="modal-overlay" onClick={() => !creating && setShowModal(false)}>
|
||||
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
||||
<h2>🎯 Novo Hábito</h2>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Nome do Hábito *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newHabit.name}
|
||||
onChange={(e) => setNewHabit({...newHabit, name: e.target.value})}
|
||||
placeholder="Ex: Fazer exercícios"
|
||||
autoFocus
|
||||
disabled={creating}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Descrição</label>
|
||||
<textarea
|
||||
value={newHabit.description}
|
||||
onChange={(e) => setNewHabit({...newHabit, description: e.target.value})}
|
||||
placeholder="Detalhes sobre o hábito..."
|
||||
rows={3}
|
||||
disabled={creating}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Frequência</label>
|
||||
<select
|
||||
value={newHabit.frequency_type}
|
||||
onChange={(e) => setNewHabit({...newHabit, frequency_type: e.target.value})}
|
||||
disabled={creating}
|
||||
>
|
||||
<option value="daily">Diário</option>
|
||||
<option value="weekly">Semanal</option>
|
||||
<option value="custom">Personalizado</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="modal-actions">
|
||||
<button
|
||||
onClick={() => setShowModal(false)}
|
||||
className="cancel-button"
|
||||
disabled={creating}
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
onClick={createHabit}
|
||||
className="create-button"
|
||||
disabled={creating}
|
||||
>
|
||||
{creating ? '⏳ Criando...' : 'Criar Hábito'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Habits;
|
||||
263
frontend/src/pages/Health.css
Normal file
263
frontend/src/pages/Health.css
Normal file
@@ -0,0 +1,263 @@
|
||||
.health-page {
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.health-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 30px;
|
||||
flex-wrap: wrap;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.health-header h1 {
|
||||
color: white;
|
||||
font-size: 2rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.back-button, .add-button {
|
||||
padding: 12px 24px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.back-button {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.back-button:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
transform: translateX(-5px);
|
||||
}
|
||||
|
||||
.add-button {
|
||||
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.add-button:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.health-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* Stats */
|
||||
.health-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 20px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.stat-box {
|
||||
background: white;
|
||||
padding: 20px;
|
||||
border-radius: 12px;
|
||||
text-align: center;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
display: block;
|
||||
font-size: 0.9rem;
|
||||
color: #666;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
display: block;
|
||||
font-size: 1.8rem;
|
||||
font-weight: bold;
|
||||
color: #667eea;
|
||||
}
|
||||
|
||||
.stat-value.positive {
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.stat-value.negative {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
/* Formulário */
|
||||
.health-form {
|
||||
background: white;
|
||||
padding: 30px;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 30px;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.health-form h2 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 20px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 15px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
font-weight: 600;
|
||||
margin-bottom: 5px;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group textarea {
|
||||
padding: 10px;
|
||||
border: 2px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
transition: border-color 0.3s ease;
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group textarea:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
.submit-button {
|
||||
width: 100%;
|
||||
padding: 15px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.submit-button:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
/* Lista de métricas */
|
||||
.metrics-list {
|
||||
background: white;
|
||||
padding: 30px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.metrics-list h2 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 20px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.empty-message {
|
||||
text-align: center;
|
||||
color: #999;
|
||||
padding: 40px;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.metrics-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
|
||||
padding: 20px;
|
||||
border-radius: 12px;
|
||||
border: 2px solid #e0e0e0;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.metric-card:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.metric-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 15px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 2px solid rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.metric-date {
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.delete-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.3rem;
|
||||
cursor: pointer;
|
||||
opacity: 0.6;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.delete-btn:hover {
|
||||
opacity: 1;
|
||||
transform: scale(1.2);
|
||||
}
|
||||
|
||||
.metric-details p {
|
||||
margin: 8px 0;
|
||||
color: #555;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.metric-details strong {
|
||||
color: #333;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.metric-details .notes {
|
||||
margin-top: 15px;
|
||||
padding-top: 15px;
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.1);
|
||||
font-style: italic;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.health-header h1 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.metrics-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
346
frontend/src/pages/Health.tsx
Normal file
346
frontend/src/pages/Health.tsx
Normal file
@@ -0,0 +1,346 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import './Health.css';
|
||||
|
||||
interface HealthProps {
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
interface HealthMetric {
|
||||
id: string;
|
||||
measurement_date: string;
|
||||
weight?: number;
|
||||
height?: number;
|
||||
body_fat_percentage?: number;
|
||||
muscle_mass?: number;
|
||||
waist?: number;
|
||||
chest?: number;
|
||||
hips?: number;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
const Health: React.FC<HealthProps> = ({ onBack }) => {
|
||||
const { token } = useAuth();
|
||||
const [metrics, setMetrics] = useState<HealthMetric[]>([]);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [formData, setFormData] = useState({
|
||||
measurement_date: new Date().toISOString().split('T')[0],
|
||||
weight: '',
|
||||
height: '',
|
||||
body_fat_percentage: '',
|
||||
muscle_mass: '',
|
||||
waist: '',
|
||||
chest: '',
|
||||
hips: '',
|
||||
notes: ''
|
||||
});
|
||||
const [stats, setStats] = useState({
|
||||
current_weight: null as number | null,
|
||||
weight_change: null as number | null,
|
||||
total_measurements: 0
|
||||
});
|
||||
|
||||
const backendUrl = `${window.location.protocol}//${window.location.host}`;
|
||||
|
||||
useEffect(() => {
|
||||
loadMetrics();
|
||||
loadStats();
|
||||
}, []);
|
||||
|
||||
const loadMetrics = async () => {
|
||||
try {
|
||||
const response = await fetch(`${backendUrl}/api/v1/health/`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setMetrics(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erro ao carregar métricas:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const loadStats = async () => {
|
||||
try {
|
||||
const response = await fetch(`${backendUrl}/api/v1/health/stats/summary`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setStats(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erro ao carregar estatísticas:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const payload: any = {
|
||||
measurement_date: formData.measurement_date,
|
||||
};
|
||||
|
||||
// Adicionar apenas campos preenchidos
|
||||
if (formData.weight) payload.weight = parseFloat(formData.weight);
|
||||
if (formData.height) payload.height = parseFloat(formData.height);
|
||||
if (formData.body_fat_percentage) payload.body_fat_percentage = parseFloat(formData.body_fat_percentage);
|
||||
if (formData.muscle_mass) payload.muscle_mass = parseFloat(formData.muscle_mass);
|
||||
if (formData.waist) payload.waist = parseFloat(formData.waist);
|
||||
if (formData.chest) payload.chest = parseFloat(formData.chest);
|
||||
if (formData.hips) payload.hips = parseFloat(formData.hips);
|
||||
if (formData.notes) payload.notes = formData.notes;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${backendUrl}/api/v1/health/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
alert('Métrica registrada com sucesso! 🎉');
|
||||
setShowForm(false);
|
||||
setFormData({
|
||||
measurement_date: new Date().toISOString().split('T')[0],
|
||||
weight: '',
|
||||
height: '',
|
||||
body_fat_percentage: '',
|
||||
muscle_mass: '',
|
||||
waist: '',
|
||||
chest: '',
|
||||
hips: '',
|
||||
notes: ''
|
||||
});
|
||||
loadMetrics();
|
||||
loadStats();
|
||||
} else {
|
||||
const error = await response.json();
|
||||
alert(`Erro: ${error.detail || 'Não foi possível registrar'}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erro ao registrar métrica:', error);
|
||||
alert('Erro ao registrar métrica');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('Deseja realmente deletar esta métrica?')) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${backendUrl}/api/v1/health/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
loadMetrics();
|
||||
loadStats();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erro ao deletar métrica:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="health-page">
|
||||
<header className="health-header">
|
||||
<button onClick={onBack} className="back-button">← Voltar</button>
|
||||
<h1>💪 Métricas de Saúde</h1>
|
||||
<button onClick={() => setShowForm(!showForm)} className="add-button">
|
||||
{showForm ? '❌ Cancelar' : '➕ Registrar'}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="health-container">
|
||||
{/* Resumo/Stats */}
|
||||
<section className="health-stats">
|
||||
<div className="stat-box">
|
||||
<span className="stat-label">Peso Atual</span>
|
||||
<span className="stat-value">
|
||||
{stats.current_weight ? `${stats.current_weight} kg` : '--'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="stat-box">
|
||||
<span className="stat-label">Variação</span>
|
||||
<span className={`stat-value ${stats.weight_change && stats.weight_change > 0 ? 'positive' : 'negative'}`}>
|
||||
{stats.weight_change
|
||||
? `${stats.weight_change > 0 ? '+' : ''}${stats.weight_change.toFixed(1)} kg`
|
||||
: '--'
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
<div className="stat-box">
|
||||
<span className="stat-label">Total de Registros</span>
|
||||
<span className="stat-value">{stats.total_measurements}</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Formulário */}
|
||||
{showForm && (
|
||||
<section className="health-form">
|
||||
<h2>📝 Registrar Nova Métrica</h2>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label>Data</label>
|
||||
<input
|
||||
type="date"
|
||||
value={formData.measurement_date}
|
||||
onChange={e => setFormData({ ...formData, measurement_date: e.target.value })}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label>Peso (kg)</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.1"
|
||||
value={formData.weight}
|
||||
onChange={e => setFormData({ ...formData, weight: e.target.value })}
|
||||
placeholder="Ex: 75.5"
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Altura (cm)</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.1"
|
||||
value={formData.height}
|
||||
onChange={e => setFormData({ ...formData, height: e.target.value })}
|
||||
placeholder="Ex: 175"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label>% Gordura</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.1"
|
||||
value={formData.body_fat_percentage}
|
||||
onChange={e => setFormData({ ...formData, body_fat_percentage: e.target.value })}
|
||||
placeholder="Ex: 15.5"
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Massa Muscular (kg)</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.1"
|
||||
value={formData.muscle_mass}
|
||||
onChange={e => setFormData({ ...formData, muscle_mass: e.target.value })}
|
||||
placeholder="Ex: 60"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label>Cintura (cm)</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.1"
|
||||
value={formData.waist}
|
||||
onChange={e => setFormData({ ...formData, waist: e.target.value })}
|
||||
placeholder="Ex: 85"
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Peito (cm)</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.1"
|
||||
value={formData.chest}
|
||||
onChange={e => setFormData({ ...formData, chest: e.target.value })}
|
||||
placeholder="Ex: 100"
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Quadril (cm)</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.1"
|
||||
value={formData.hips}
|
||||
onChange={e => setFormData({ ...formData, hips: e.target.value })}
|
||||
placeholder="Ex: 95"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Observações</label>
|
||||
<textarea
|
||||
value={formData.notes}
|
||||
onChange={e => setFormData({ ...formData, notes: e.target.value })}
|
||||
placeholder="Anote detalhes importantes..."
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button type="submit" className="submit-button">
|
||||
💾 Salvar Métrica
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Lista de métricas */}
|
||||
<section className="metrics-list">
|
||||
<h2>📊 Histórico de Medições</h2>
|
||||
{metrics.length === 0 ? (
|
||||
<p className="empty-message">Nenhuma métrica registrada ainda. Comece agora! 💪</p>
|
||||
) : (
|
||||
<div className="metrics-grid">
|
||||
{metrics.map(metric => (
|
||||
<div key={metric.id} className="metric-card">
|
||||
<div className="metric-header">
|
||||
<span className="metric-date">
|
||||
{new Date(metric.measurement_date).toLocaleDateString('pt-BR')}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handleDelete(metric.id)}
|
||||
className="delete-btn"
|
||||
title="Deletar"
|
||||
>
|
||||
🗑️
|
||||
</button>
|
||||
</div>
|
||||
<div className="metric-details">
|
||||
{metric.weight && <p>⚖️ Peso: <strong>{metric.weight} kg</strong></p>}
|
||||
{metric.height && <p>📏 Altura: <strong>{metric.height} cm</strong></p>}
|
||||
{metric.body_fat_percentage && <p>📊 Gordura: <strong>{metric.body_fat_percentage}%</strong></p>}
|
||||
{metric.muscle_mass && <p>💪 Músculo: <strong>{metric.muscle_mass} kg</strong></p>}
|
||||
{metric.waist && <p>📐 Cintura: <strong>{metric.waist} cm</strong></p>}
|
||||
{metric.chest && <p>🎯 Peito: <strong>{metric.chest} cm</strong></p>}
|
||||
{metric.hips && <p>📏 Quadril: <strong>{metric.hips} cm</strong></p>}
|
||||
{metric.notes && <p className="notes">📝 {metric.notes}</p>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Health;
|
||||
|
||||
85
frontend/src/pages/Login.tsx
Normal file
85
frontend/src/pages/Login.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import './Auth.css';
|
||||
|
||||
const Login: React.FC<{ onSwitchToRegister: () => void }> = ({ onSwitchToRegister }) => {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { login } = useAuth();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
await login(email, password);
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Erro ao fazer login');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="auth-container">
|
||||
<div className="auth-box">
|
||||
<div className="auth-header">
|
||||
<h1>🚀 VIDA180</h1>
|
||||
<p>Entre na sua conta</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="auth-form">
|
||||
{error && (
|
||||
<div className="error-message">
|
||||
❌ {error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="email">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="seu@email.com"
|
||||
required
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="password">Senha</label>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
required
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button type="submit" className="auth-button" disabled={loading}>
|
||||
{loading ? '⏳ Entrando...' : 'Entrar'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="auth-footer">
|
||||
<p>
|
||||
Não tem uma conta?{' '}
|
||||
<button onClick={onSwitchToRegister} className="link-button">
|
||||
Cadastre-se
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Login;
|
||||
179
frontend/src/pages/Progress.css
Normal file
179
frontend/src/pages/Progress.css
Normal file
@@ -0,0 +1,179 @@
|
||||
.progress-page {
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.progress-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 30px;
|
||||
flex-wrap: wrap;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.progress-header h1 {
|
||||
color: white;
|
||||
font-size: 2rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.progress-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.progress-section {
|
||||
background: white;
|
||||
padding: 30px;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 30px;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.progress-section h2 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 25px;
|
||||
color: #333;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
|
||||
padding: 20px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
transition: all 0.3s ease;
|
||||
border-left: 4px solid #6b7280;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.stat-card.pending {
|
||||
border-left-color: #f59e0b;
|
||||
}
|
||||
|
||||
.stat-card.progress {
|
||||
border-left-color: #3b82f6;
|
||||
}
|
||||
|
||||
.stat-card.completed {
|
||||
border-left-color: #10b981;
|
||||
}
|
||||
|
||||
.stat-card.today {
|
||||
border-left-color: #8b5cf6;
|
||||
}
|
||||
|
||||
.stat-card.completion {
|
||||
border-left-color: #ec4899;
|
||||
}
|
||||
|
||||
.stat-card.positive {
|
||||
border-left-color: #10b981;
|
||||
}
|
||||
|
||||
.stat-card.negative {
|
||||
border-left-color: #ef4444;
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
font-size: 2.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.stat-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.stat-content h3 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 0.9rem;
|
||||
color: #666;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.stat-number {
|
||||
margin: 0;
|
||||
font-size: 2rem;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
color: #999;
|
||||
padding: 20px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Motivação */
|
||||
.motivation-section {
|
||||
margin-top: 40px;
|
||||
}
|
||||
|
||||
.motivation-card {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
padding: 40px;
|
||||
border-radius: 12px;
|
||||
text-align: center;
|
||||
color: white;
|
||||
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.motivation-card h2 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 20px;
|
||||
font-size: 2rem;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.motivation-card p {
|
||||
font-size: 1.1rem;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.motivation-quote {
|
||||
font-style: italic;
|
||||
font-size: 1.3rem;
|
||||
margin-top: 25px;
|
||||
padding-top: 25px;
|
||||
border-top: 2px solid rgba(255, 255, 255, 0.3);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.progress-header h1 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.motivation-card {
|
||||
padding: 30px 20px;
|
||||
}
|
||||
|
||||
.motivation-card h2 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.motivation-quote {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
}
|
||||
|
||||
236
frontend/src/pages/Progress.tsx
Normal file
236
frontend/src/pages/Progress.tsx
Normal file
@@ -0,0 +1,236 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import './Progress.css';
|
||||
|
||||
interface ProgressProps {
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
const Progress: React.FC<ProgressProps> = ({ onBack }) => {
|
||||
const { token } = useAuth();
|
||||
const [habitStats, setHabitStats] = useState<any>(null);
|
||||
const [taskStats, setTaskStats] = useState<any>(null);
|
||||
const [healthStats, setHealthStats] = useState<any>(null);
|
||||
|
||||
const backendUrl = `${window.location.protocol}//${window.location.host}`;
|
||||
|
||||
useEffect(() => {
|
||||
loadAllStats();
|
||||
}, []);
|
||||
|
||||
const loadAllStats = async () => {
|
||||
try {
|
||||
// Carregar estatísticas de hábitos
|
||||
const habitsRes = await fetch(`${backendUrl}/api/v1/habits/stats`, {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
if (habitsRes.ok) {
|
||||
setHabitStats(await habitsRes.json());
|
||||
}
|
||||
|
||||
// Carregar estatísticas de tarefas
|
||||
const tasksRes = await fetch(`${backendUrl}/api/v1/tasks/stats/summary`, {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
if (tasksRes.ok) {
|
||||
setTaskStats(await tasksRes.json());
|
||||
}
|
||||
|
||||
// Carregar estatísticas de saúde
|
||||
const healthRes = await fetch(`${backendUrl}/api/v1/health/stats/summary`, {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
if (healthRes.ok) {
|
||||
setHealthStats(await healthRes.json());
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erro ao carregar estatísticas:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const calculateCompletionRate = (completed: number, total: number) => {
|
||||
if (total === 0) return 0;
|
||||
return Math.round((completed / total) * 100);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="progress-page">
|
||||
<header className="progress-header">
|
||||
<button onClick={onBack} className="back-button">← Voltar</button>
|
||||
<h1>📈 Progresso e Estatísticas</h1>
|
||||
</header>
|
||||
|
||||
<div className="progress-container">
|
||||
{/* Hábitos */}
|
||||
<section className="progress-section">
|
||||
<h2>🎯 Hábitos</h2>
|
||||
{habitStats ? (
|
||||
<div className="stats-grid">
|
||||
<div className="stat-card">
|
||||
<div className="stat-icon">📊</div>
|
||||
<div className="stat-content">
|
||||
<h3>Total de Hábitos</h3>
|
||||
<p className="stat-number">{habitStats.total_habits}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-icon">✅</div>
|
||||
<div className="stat-content">
|
||||
<h3>Completados Hoje</h3>
|
||||
<p className="stat-number">{habitStats.completed_today}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-icon">⏳</div>
|
||||
<div className="stat-content">
|
||||
<h3>Pendentes Hoje</h3>
|
||||
<p className="stat-number">{habitStats.pending_today}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="stat-card completion">
|
||||
<div className="stat-icon">🎯</div>
|
||||
<div className="stat-content">
|
||||
<h3>Taxa de Conclusão</h3>
|
||||
<p className="stat-number">
|
||||
{calculateCompletionRate(habitStats.completed_today, habitStats.total_habits)}%
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="loading">Carregando...</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Tarefas */}
|
||||
<section className="progress-section">
|
||||
<h2>📝 Tarefas</h2>
|
||||
{taskStats ? (
|
||||
<div className="stats-grid">
|
||||
<div className="stat-card">
|
||||
<div className="stat-icon">📊</div>
|
||||
<div className="stat-content">
|
||||
<h3>Total de Tarefas</h3>
|
||||
<p className="stat-number">{taskStats.total_tasks}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="stat-card pending">
|
||||
<div className="stat-icon">⏳</div>
|
||||
<div className="stat-content">
|
||||
<h3>Pendentes</h3>
|
||||
<p className="stat-number">{taskStats.pending}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="stat-card progress">
|
||||
<div className="stat-icon">🔄</div>
|
||||
<div className="stat-content">
|
||||
<h3>Em Progresso</h3>
|
||||
<p className="stat-number">{taskStats.in_progress}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="stat-card completed">
|
||||
<div className="stat-icon">✅</div>
|
||||
<div className="stat-content">
|
||||
<h3>Concluídas</h3>
|
||||
<p className="stat-number">{taskStats.completed}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="stat-card today">
|
||||
<div className="stat-icon">📅</div>
|
||||
<div className="stat-content">
|
||||
<h3>Tarefas Hoje</h3>
|
||||
<p className="stat-number">{taskStats.today_completed}/{taskStats.today_tasks}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="stat-card completion">
|
||||
<div className="stat-icon">🎯</div>
|
||||
<div className="stat-content">
|
||||
<h3>Taxa de Conclusão</h3>
|
||||
<p className="stat-number">
|
||||
{calculateCompletionRate(taskStats.completed, taskStats.total_tasks)}%
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="loading">Carregando...</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Saúde */}
|
||||
<section className="progress-section">
|
||||
<h2>💪 Saúde</h2>
|
||||
{healthStats ? (
|
||||
<div className="stats-grid">
|
||||
<div className="stat-card">
|
||||
<div className="stat-icon">⚖️</div>
|
||||
<div className="stat-content">
|
||||
<h3>Peso Atual</h3>
|
||||
<p className="stat-number">
|
||||
{healthStats.current_weight ? `${healthStats.current_weight} kg` : '--'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-icon">📏</div>
|
||||
<div className="stat-content">
|
||||
<h3>Altura</h3>
|
||||
<p className="stat-number">
|
||||
{healthStats.current_height ? `${healthStats.current_height} cm` : '--'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-icon">📊</div>
|
||||
<div className="stat-content">
|
||||
<h3>% Gordura</h3>
|
||||
<p className="stat-number">
|
||||
{healthStats.current_body_fat ? `${healthStats.current_body_fat}%` : '--'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`stat-card ${healthStats.weight_change && healthStats.weight_change > 0 ? 'positive' : 'negative'}`}>
|
||||
<div className="stat-icon">📈</div>
|
||||
<div className="stat-content">
|
||||
<h3>Variação de Peso</h3>
|
||||
<p className="stat-number">
|
||||
{healthStats.weight_change
|
||||
? `${healthStats.weight_change > 0 ? '+' : ''}${healthStats.weight_change.toFixed(1)} kg`
|
||||
: '--'
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-icon">📋</div>
|
||||
<div className="stat-content">
|
||||
<h3>Total de Registros</h3>
|
||||
<p className="stat-number">{healthStats.total_measurements}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="loading">Carregando...</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Mensagem motivacional */}
|
||||
<section className="motivation-section">
|
||||
<div className="motivation-card">
|
||||
<h2>💪 Continue Assim!</h2>
|
||||
<p>
|
||||
Você está no caminho certo para transformar sua vida!
|
||||
Cada pequeno passo conta na sua jornada de 180 dias.
|
||||
</p>
|
||||
<p className="motivation-quote">
|
||||
"O sucesso é a soma de pequenos esforços repetidos dia após dia." 🚀
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Progress;
|
||||
|
||||
186
frontend/src/pages/Register.tsx
Normal file
186
frontend/src/pages/Register.tsx
Normal file
@@ -0,0 +1,186 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import './Auth.css';
|
||||
|
||||
interface RegisterProps {
|
||||
onSwitchToLogin: () => void;
|
||||
}
|
||||
|
||||
const Register: React.FC<RegisterProps> = ({ onSwitchToLogin }) => {
|
||||
const { register } = useAuth();
|
||||
const [formData, setFormData] = useState({
|
||||
fullName: '',
|
||||
phone: '',
|
||||
email: '',
|
||||
username: '',
|
||||
password: '',
|
||||
confirmPassword: ''
|
||||
});
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const formatPhone = (value: string) => {
|
||||
// Remove tudo que não é número
|
||||
const numbers = value.replace(/\D/g, '');
|
||||
|
||||
// Formata (XX) XXXXX-XXXX ou (XX) XXXX-XXXX
|
||||
if (numbers.length <= 10) {
|
||||
return numbers.replace(/(\d{2})(\d{4})(\d{0,4})/, '($1) $2-$3');
|
||||
}
|
||||
return numbers.replace(/(\d{2})(\d{5})(\d{0,4})/, '($1) $2-$3');
|
||||
};
|
||||
|
||||
const handlePhoneChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const formatted = formatPhone(e.target.value);
|
||||
setFormData({ ...formData, phone: formatted });
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
|
||||
// Validações
|
||||
if (!formData.fullName.trim() || formData.fullName.trim().length < 3) {
|
||||
setError('Nome completo deve ter no mínimo 3 caracteres');
|
||||
return;
|
||||
}
|
||||
|
||||
const phoneNumbers = formData.phone.replace(/\D/g, '');
|
||||
if (phoneNumbers.length < 10 || phoneNumbers.length > 11) {
|
||||
setError('Telefone inválido. Digite (XX) XXXXX-XXXX');
|
||||
return;
|
||||
}
|
||||
|
||||
if (formData.password.length < 6) {
|
||||
setError('Senha deve ter no mínimo 6 caracteres');
|
||||
return;
|
||||
}
|
||||
|
||||
if (formData.password !== formData.confirmPassword) {
|
||||
setError('As senhas não coincidem');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
await register(
|
||||
formData.email,
|
||||
formData.username,
|
||||
formData.password,
|
||||
formData.fullName,
|
||||
formData.phone
|
||||
);
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Erro ao criar conta');
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="auth-container">
|
||||
<div className="auth-box">
|
||||
<div className="auth-header">
|
||||
<h1>🚀 VIDA180</h1>
|
||||
<p>Crie sua conta e transforme sua vida</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="auth-form">
|
||||
{error && <div className="error-message">❌ {error}</div>}
|
||||
|
||||
<div className="form-group">
|
||||
<label>Nome Completo *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.fullName}
|
||||
onChange={(e) => setFormData({ ...formData, fullName: e.target.value })}
|
||||
placeholder="Seu nome completo"
|
||||
disabled={loading}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Celular *</label>
|
||||
<input
|
||||
type="tel"
|
||||
value={formData.phone}
|
||||
onChange={handlePhoneChange}
|
||||
placeholder="(11) 98765-4321"
|
||||
maxLength={15}
|
||||
disabled={loading}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Email *</label>
|
||||
<input
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||||
placeholder="seu@email.com"
|
||||
disabled={loading}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Nome de Usuário *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.username}
|
||||
onChange={(e) => setFormData({ ...formData, username: e.target.value })}
|
||||
placeholder="seu_usuario"
|
||||
disabled={loading}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Senha *</label>
|
||||
<input
|
||||
type="password"
|
||||
value={formData.password}
|
||||
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||||
placeholder="Mínimo 6 caracteres"
|
||||
disabled={loading}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Confirmar Senha *</label>
|
||||
<input
|
||||
type="password"
|
||||
value={formData.confirmPassword}
|
||||
onChange={(e) => setFormData({ ...formData, confirmPassword: e.target.value })}
|
||||
placeholder="Digite a senha novamente"
|
||||
disabled={loading}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button type="submit" className="auth-button" disabled={loading}>
|
||||
{loading ? 'Criando conta...' : 'Criar Conta'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="auth-footer">
|
||||
<p>
|
||||
Já tem uma conta?{' '}
|
||||
<button onClick={onSwitchToLogin} className="link-button">
|
||||
Entrar
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="verification-notice">
|
||||
📧 Após criar sua conta, você receberá um email de confirmação.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Register;
|
||||
306
frontend/src/pages/Tasks.css
Normal file
306
frontend/src/pages/Tasks.css
Normal file
@@ -0,0 +1,306 @@
|
||||
.tasks-page {
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.tasks-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 30px;
|
||||
flex-wrap: wrap;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.tasks-header h1 {
|
||||
color: white;
|
||||
font-size: 2rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.tasks-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* Stats */
|
||||
.tasks-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 15px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.stat-box {
|
||||
background: white;
|
||||
padding: 20px;
|
||||
border-radius: 12px;
|
||||
text-align: center;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.stat-box:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.stat-box.pending {
|
||||
border-top: 4px solid #f59e0b;
|
||||
}
|
||||
|
||||
.stat-box.progress {
|
||||
border-top: 4px solid #3b82f6;
|
||||
}
|
||||
|
||||
.stat-box.completed {
|
||||
border-top: 4px solid #10b981;
|
||||
}
|
||||
|
||||
.stat-box.today {
|
||||
border-top: 4px solid #8b5cf6;
|
||||
}
|
||||
|
||||
/* Formulário */
|
||||
.tasks-form {
|
||||
background: white;
|
||||
padding: 30px;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 30px;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.tasks-form h2 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 20px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 15px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
font-weight: 600;
|
||||
margin-bottom: 5px;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group textarea,
|
||||
.form-group select {
|
||||
padding: 10px;
|
||||
border: 2px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
transition: border-color 0.3s ease;
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group textarea:focus,
|
||||
.form-group select:focus {
|
||||
outline: none;
|
||||
border-color: #f5576c;
|
||||
}
|
||||
|
||||
.submit-button {
|
||||
width: 100%;
|
||||
padding: 15px;
|
||||
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.submit-button:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
/* Filtros */
|
||||
.tasks-filters {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 30px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tasks-filters button {
|
||||
padding: 10px 20px;
|
||||
background: white;
|
||||
border: 2px solid transparent;
|
||||
border-radius: 8px;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.tasks-filters button:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.tasks-filters button.active {
|
||||
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Lista de tarefas */
|
||||
.tasks-list {
|
||||
background: white;
|
||||
padding: 30px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.empty-message {
|
||||
text-align: center;
|
||||
color: #999;
|
||||
padding: 40px;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.tasks-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.task-card {
|
||||
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
|
||||
padding: 20px;
|
||||
border-radius: 12px;
|
||||
border-left: 5px solid #6b7280;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.task-card:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.task-card.pending {
|
||||
border-left-color: #f59e0b;
|
||||
}
|
||||
|
||||
.task-card.in_progress {
|
||||
border-left-color: #3b82f6;
|
||||
}
|
||||
|
||||
.task-card.completed {
|
||||
border-left-color: #10b981;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.task-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.task-header h3 {
|
||||
margin: 0;
|
||||
color: #333;
|
||||
font-size: 1.2rem;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.priority-badge {
|
||||
padding: 5px 12px;
|
||||
border-radius: 20px;
|
||||
color: white;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.task-description {
|
||||
color: #555;
|
||||
margin: 10px 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.task-meta {
|
||||
margin: 15px 0;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.task-date {
|
||||
color: #666;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.task-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 15px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.status-select {
|
||||
flex: 1;
|
||||
padding: 8px;
|
||||
border: 2px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.3s ease;
|
||||
}
|
||||
|
||||
.status-select:focus {
|
||||
outline: none;
|
||||
border-color: #f5576c;
|
||||
}
|
||||
|
||||
.delete-btn {
|
||||
background: #ef4444;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 8px 15px;
|
||||
border-radius: 8px;
|
||||
font-size: 1.1rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.delete-btn:hover {
|
||||
background: #dc2626;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.tasks-header h1 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.tasks-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.tasks-filters {
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
401
frontend/src/pages/Tasks.tsx
Normal file
401
frontend/src/pages/Tasks.tsx
Normal file
@@ -0,0 +1,401 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import './Tasks.css';
|
||||
|
||||
interface TasksProps {
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
interface Task {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
priority: string;
|
||||
status: string;
|
||||
due_date?: string;
|
||||
due_time?: string;
|
||||
is_archived: boolean;
|
||||
}
|
||||
|
||||
const Tasks: React.FC<TasksProps> = ({ onBack }) => {
|
||||
const { token } = useAuth();
|
||||
const [tasks, setTasks] = useState<Task[]>([]);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [filterStatus, setFilterStatus] = useState<string>('all');
|
||||
const [formData, setFormData] = useState({
|
||||
title: '',
|
||||
description: '',
|
||||
priority: 'medium',
|
||||
status: 'pending',
|
||||
due_date: '',
|
||||
due_time: ''
|
||||
});
|
||||
const [stats, setStats] = useState({
|
||||
total_tasks: 0,
|
||||
pending: 0,
|
||||
in_progress: 0,
|
||||
completed: 0,
|
||||
today_tasks: 0,
|
||||
today_completed: 0
|
||||
});
|
||||
|
||||
const backendUrl = `${window.location.protocol}//${window.location.host}`;
|
||||
|
||||
useEffect(() => {
|
||||
loadTasks();
|
||||
loadStats();
|
||||
}, [filterStatus]);
|
||||
|
||||
const loadTasks = async () => {
|
||||
try {
|
||||
const url = filterStatus === 'all'
|
||||
? `${backendUrl}/api/v1/tasks/`
|
||||
: `${backendUrl}/api/v1/tasks/?status_filter=${filterStatus}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setTasks(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erro ao carregar tarefas:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const loadStats = async () => {
|
||||
try {
|
||||
const response = await fetch(`${backendUrl}/api/v1/tasks/stats/summary`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setStats(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erro ao carregar estatísticas:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const payload: any = {
|
||||
title: formData.title,
|
||||
description: formData.description || null,
|
||||
priority: formData.priority,
|
||||
status: formData.status
|
||||
};
|
||||
|
||||
if (formData.due_date) payload.due_date = formData.due_date;
|
||||
if (formData.due_time) payload.due_time = formData.due_time;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${backendUrl}/api/v1/tasks/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
alert('Tarefa criada com sucesso! 🎉');
|
||||
setShowForm(false);
|
||||
setFormData({
|
||||
title: '',
|
||||
description: '',
|
||||
priority: 'medium',
|
||||
status: 'pending',
|
||||
due_date: '',
|
||||
due_time: ''
|
||||
});
|
||||
loadTasks();
|
||||
loadStats();
|
||||
} else {
|
||||
const error = await response.json();
|
||||
alert(`Erro: ${error.detail || 'Não foi possível criar tarefa'}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erro ao criar tarefa:', error);
|
||||
alert('Erro ao criar tarefa');
|
||||
}
|
||||
};
|
||||
|
||||
const updateTaskStatus = async (taskId: string, newStatus: string) => {
|
||||
try {
|
||||
const response = await fetch(`${backendUrl}/api/v1/tasks/${taskId}/status?status=${newStatus}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
loadTasks();
|
||||
loadStats();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erro ao atualizar status:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('Deseja realmente deletar esta tarefa?')) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${backendUrl}/api/v1/tasks/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
loadTasks();
|
||||
loadStats();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erro ao deletar tarefa:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const getPriorityColor = (priority: string) => {
|
||||
switch (priority) {
|
||||
case 'high': return '#ef4444';
|
||||
case 'medium': return '#f59e0b';
|
||||
case 'low': return '#10b981';
|
||||
default: return '#6b7280';
|
||||
}
|
||||
};
|
||||
|
||||
const getPriorityLabel = (priority: string) => {
|
||||
switch (priority) {
|
||||
case 'high': return '🔴 Alta';
|
||||
case 'medium': return '🟡 Média';
|
||||
case 'low': return '🟢 Baixa';
|
||||
default: return priority;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusLabel = (status: string) => {
|
||||
switch (status) {
|
||||
case 'pending': return '⏳ Pendente';
|
||||
case 'in_progress': return '🔄 Em Progresso';
|
||||
case 'completed': return '✅ Concluída';
|
||||
default: return status;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="tasks-page">
|
||||
<header className="tasks-header">
|
||||
<button onClick={onBack} className="back-button">← Voltar</button>
|
||||
<h1>📝 Tarefas</h1>
|
||||
<button onClick={() => setShowForm(!showForm)} className="add-button">
|
||||
{showForm ? '❌ Cancelar' : '➕ Nova Tarefa'}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="tasks-container">
|
||||
{/* Stats */}
|
||||
<section className="tasks-stats">
|
||||
<div className="stat-box">
|
||||
<span className="stat-label">Total</span>
|
||||
<span className="stat-value">{stats.total_tasks}</span>
|
||||
</div>
|
||||
<div className="stat-box pending">
|
||||
<span className="stat-label">Pendentes</span>
|
||||
<span className="stat-value">{stats.pending}</span>
|
||||
</div>
|
||||
<div className="stat-box progress">
|
||||
<span className="stat-label">Em Progresso</span>
|
||||
<span className="stat-value">{stats.in_progress}</span>
|
||||
</div>
|
||||
<div className="stat-box completed">
|
||||
<span className="stat-label">Concluídas</span>
|
||||
<span className="stat-value">{stats.completed}</span>
|
||||
</div>
|
||||
<div className="stat-box today">
|
||||
<span className="stat-label">Hoje</span>
|
||||
<span className="stat-value">{stats.today_completed}/{stats.today_tasks}</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Formulário */}
|
||||
{showForm && (
|
||||
<section className="tasks-form">
|
||||
<h2>➕ Nova Tarefa</h2>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="form-group">
|
||||
<label>Título *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.title}
|
||||
onChange={e => setFormData({ ...formData, title: e.target.value })}
|
||||
placeholder="Ex: Fazer exercícios"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Descrição</label>
|
||||
<textarea
|
||||
value={formData.description}
|
||||
onChange={e => setFormData({ ...formData, description: e.target.value })}
|
||||
placeholder="Descreva os detalhes da tarefa..."
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label>Prioridade</label>
|
||||
<select
|
||||
value={formData.priority}
|
||||
onChange={e => setFormData({ ...formData, priority: e.target.value })}
|
||||
>
|
||||
<option value="low">🟢 Baixa</option>
|
||||
<option value="medium">🟡 Média</option>
|
||||
<option value="high">🔴 Alta</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Status</label>
|
||||
<select
|
||||
value={formData.status}
|
||||
onChange={e => setFormData({ ...formData, status: e.target.value })}
|
||||
>
|
||||
<option value="pending">⏳ Pendente</option>
|
||||
<option value="in_progress">🔄 Em Progresso</option>
|
||||
<option value="completed">✅ Concluída</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label>Data</label>
|
||||
<input
|
||||
type="date"
|
||||
value={formData.due_date}
|
||||
onChange={e => setFormData({ ...formData, due_date: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Horário</label>
|
||||
<input
|
||||
type="time"
|
||||
value={formData.due_time}
|
||||
onChange={e => setFormData({ ...formData, due_time: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" className="submit-button">
|
||||
💾 Criar Tarefa
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Filtros */}
|
||||
<section className="tasks-filters">
|
||||
<button
|
||||
className={filterStatus === 'all' ? 'active' : ''}
|
||||
onClick={() => setFilterStatus('all')}
|
||||
>
|
||||
Todas
|
||||
</button>
|
||||
<button
|
||||
className={filterStatus === 'pending' ? 'active' : ''}
|
||||
onClick={() => setFilterStatus('pending')}
|
||||
>
|
||||
⏳ Pendentes
|
||||
</button>
|
||||
<button
|
||||
className={filterStatus === 'in_progress' ? 'active' : ''}
|
||||
onClick={() => setFilterStatus('in_progress')}
|
||||
>
|
||||
🔄 Em Progresso
|
||||
</button>
|
||||
<button
|
||||
className={filterStatus === 'completed' ? 'active' : ''}
|
||||
onClick={() => setFilterStatus('completed')}
|
||||
>
|
||||
✅ Concluídas
|
||||
</button>
|
||||
</section>
|
||||
|
||||
{/* Lista de tarefas */}
|
||||
<section className="tasks-list">
|
||||
{tasks.length === 0 ? (
|
||||
<p className="empty-message">
|
||||
Nenhuma tarefa encontrada. Crie sua primeira tarefa! 🚀
|
||||
</p>
|
||||
) : (
|
||||
<div className="tasks-grid">
|
||||
{tasks.map(task => (
|
||||
<div key={task.id} className={`task-card ${task.status}`}>
|
||||
<div className="task-header">
|
||||
<h3>{task.title}</h3>
|
||||
<span
|
||||
className="priority-badge"
|
||||
style={{ background: getPriorityColor(task.priority) }}
|
||||
>
|
||||
{getPriorityLabel(task.priority)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{task.description && (
|
||||
<p className="task-description">{task.description}</p>
|
||||
)}
|
||||
|
||||
<div className="task-meta">
|
||||
{task.due_date && (
|
||||
<span className="task-date">
|
||||
📅 {new Date(task.due_date).toLocaleDateString('pt-BR')}
|
||||
{task.due_time && ` às ${task.due_time}`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="task-actions">
|
||||
<select
|
||||
value={task.status}
|
||||
onChange={(e) => updateTaskStatus(task.id, e.target.value)}
|
||||
className="status-select"
|
||||
>
|
||||
<option value="pending">⏳ Pendente</option>
|
||||
<option value="in_progress">🔄 Em Progresso</option>
|
||||
<option value="completed">✅ Concluída</option>
|
||||
</select>
|
||||
<button
|
||||
onClick={() => handleDelete(task.id)}
|
||||
className="delete-btn"
|
||||
title="Deletar"
|
||||
>
|
||||
🗑️
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Tasks;
|
||||
|
||||
BIN
frontend/src/vida180_background.jpg
Normal file
BIN
frontend/src/vida180_background.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 16 MiB |
26
frontend/tsconfig.json
Normal file
26
frontend/tsconfig.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user