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:
Sergio Correa
2025-11-22 02:33:15 +00:00
commit f50174f898
68 changed files with 6835 additions and 0 deletions

18
frontend/Dockerfile Normal file
View 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
View 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"
}
}

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 MiB

43
frontend/src/App.css Normal file
View 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
View 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;

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 MiB

26
frontend/tsconfig.json Normal file
View 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"
]
}