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:
33
.gitignore
vendored
Normal file
33
.gitignore
vendored
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# Backend
|
||||||
|
backend/__pycache__/
|
||||||
|
backend/**/__pycache__/
|
||||||
|
backend/**/**/__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
*.pyd
|
||||||
|
.Python
|
||||||
|
*.so
|
||||||
|
*.egg
|
||||||
|
*.egg-info/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
.env
|
||||||
|
|
||||||
|
# Frontend
|
||||||
|
frontend/node_modules/
|
||||||
|
frontend/build/
|
||||||
|
frontend/.env
|
||||||
|
frontend/.env.local
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Docker
|
||||||
|
*.log
|
||||||
18
backend/.env.example
Normal file
18
backend/.env.example
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Database
|
||||||
|
DATABASE_URL=postgresql://vida180_user:vida180_strong_password_2024@postgres:5432/vida180_db
|
||||||
|
|
||||||
|
# Redis
|
||||||
|
REDIS_URL=redis://:vida180_redis_pass_2024@redis:6379/0
|
||||||
|
|
||||||
|
# JWT
|
||||||
|
SECRET_KEY=change-this-to-random-secret-key-in-production
|
||||||
|
ALGORITHM=HS256
|
||||||
|
ACCESS_TOKEN_EXPIRE_MINUTES=30
|
||||||
|
|
||||||
|
# App
|
||||||
|
API_V1_PREFIX=/api/v1
|
||||||
|
PROJECT_NAME=Vida180
|
||||||
|
DEBUG=True
|
||||||
|
|
||||||
|
# CORS
|
||||||
|
BACKEND_CORS_ORIGINS=["http://localhost:3000","http://localhost","http://vida180.com.br","https://vida180.com.br"]
|
||||||
24
backend/Dockerfile
Normal file
24
backend/Dockerfile
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install system dependencies
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
gcc \
|
||||||
|
postgresql-client \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Copy requirements
|
||||||
|
COPY requirements.txt .
|
||||||
|
|
||||||
|
# Install Python dependencies
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# Copy application
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Expose port
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
# Run the application
|
||||||
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
0
backend/app/__init__.py
Normal file
0
backend/app/__init__.py
Normal file
4
backend/app/api/__init__.py
Normal file
4
backend/app/api/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
from . import auth, habits, health, messages, admin, tasks
|
||||||
|
|
||||||
|
__all__ = ['auth', 'habits', 'health', 'messages', 'admin', 'tasks']
|
||||||
|
|
||||||
148
backend/app/api/admin.py
Normal file
148
backend/app/api/admin.py
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from typing import List
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.security import decode_access_token, get_password_hash
|
||||||
|
from app.models.user import User
|
||||||
|
from app.schemas.admin import UserListResponse, UserUpdateRequest, PasswordChangeRequest
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/admin", tags=["admin"])
|
||||||
|
security = HTTPBearer()
|
||||||
|
|
||||||
|
def get_current_superadmin(
|
||||||
|
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
) -> User:
|
||||||
|
"""Verifica se usuário é superadmin"""
|
||||||
|
token = credentials.credentials
|
||||||
|
payload = decode_access_token(token)
|
||||||
|
if not payload:
|
||||||
|
raise HTTPException(status_code=401, detail="Token inválido")
|
||||||
|
|
||||||
|
user_id = payload.get("sub")
|
||||||
|
user = db.query(User).filter(User.id == user_id).first()
|
||||||
|
|
||||||
|
if not user or not user.is_superadmin:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403,
|
||||||
|
detail="Acesso negado. Apenas superadmin."
|
||||||
|
)
|
||||||
|
|
||||||
|
return user
|
||||||
|
|
||||||
|
@router.get("/users", response_model=List[UserListResponse])
|
||||||
|
async def list_all_users(
|
||||||
|
admin: User = Depends(get_current_superadmin),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Listar todos os usuários (apenas superadmin)"""
|
||||||
|
users = db.query(User).order_by(User.created_at.desc()).all()
|
||||||
|
return users
|
||||||
|
|
||||||
|
@router.get("/stats")
|
||||||
|
async def get_admin_stats(
|
||||||
|
admin: User = Depends(get_current_superadmin),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Estatísticas gerais do sistema"""
|
||||||
|
from sqlalchemy import func
|
||||||
|
from app.models.habit import Habit, HabitCompletion
|
||||||
|
|
||||||
|
total_users = db.query(func.count(User.id)).scalar()
|
||||||
|
active_users = db.query(func.count(User.id)).filter(User.is_active == True).scalar()
|
||||||
|
total_habits = db.query(func.count(Habit.id)).scalar()
|
||||||
|
total_completions = db.query(func.count(HabitCompletion.id)).scalar()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total_users": total_users,
|
||||||
|
"active_users": active_users,
|
||||||
|
"inactive_users": total_users - active_users,
|
||||||
|
"total_habits": total_habits,
|
||||||
|
"total_completions": total_completions
|
||||||
|
}
|
||||||
|
|
||||||
|
@router.patch("/users/{user_id}")
|
||||||
|
async def update_user(
|
||||||
|
user_id: str,
|
||||||
|
data: UserUpdateRequest,
|
||||||
|
admin: User = Depends(get_current_superadmin),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Atualizar dados de usuário"""
|
||||||
|
user = db.query(User).filter(User.id == user_id).first()
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=404, detail="Usuário não encontrado")
|
||||||
|
|
||||||
|
# Atualizar campos
|
||||||
|
if data.email is not None:
|
||||||
|
# Verificar se email já existe
|
||||||
|
existing = db.query(User).filter(
|
||||||
|
User.email == data.email,
|
||||||
|
User.id != user_id
|
||||||
|
).first()
|
||||||
|
if existing:
|
||||||
|
raise HTTPException(status_code=400, detail="Email já em uso")
|
||||||
|
user.email = data.email
|
||||||
|
|
||||||
|
if data.username is not None:
|
||||||
|
# Verificar se username já existe
|
||||||
|
existing = db.query(User).filter(
|
||||||
|
User.username == data.username,
|
||||||
|
User.id != user_id
|
||||||
|
).first()
|
||||||
|
if existing:
|
||||||
|
raise HTTPException(status_code=400, detail="Username já em uso")
|
||||||
|
user.username = data.username
|
||||||
|
|
||||||
|
if data.full_name is not None:
|
||||||
|
user.full_name = data.full_name
|
||||||
|
|
||||||
|
if data.is_active is not None:
|
||||||
|
user.is_active = data.is_active
|
||||||
|
|
||||||
|
if data.is_verified is not None:
|
||||||
|
user.is_verified = data.is_verified
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(user)
|
||||||
|
|
||||||
|
return {"message": "Usuário atualizado", "user": user}
|
||||||
|
|
||||||
|
@router.delete("/users/{user_id}")
|
||||||
|
async def delete_user(
|
||||||
|
user_id: str,
|
||||||
|
admin: User = Depends(get_current_superadmin),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Deletar usuário (soft delete - apenas desativa)"""
|
||||||
|
user = db.query(User).filter(User.id == user_id).first()
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=404, detail="Usuário não encontrado")
|
||||||
|
|
||||||
|
if user.is_superadmin:
|
||||||
|
raise HTTPException(status_code=403, detail="Não é possível deletar superadmin")
|
||||||
|
|
||||||
|
user.is_active = False
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {"message": "Usuário desativado com sucesso"}
|
||||||
|
|
||||||
|
@router.post("/users/{user_id}/reset-password")
|
||||||
|
async def reset_user_password(
|
||||||
|
user_id: str,
|
||||||
|
data: PasswordChangeRequest,
|
||||||
|
admin: User = Depends(get_current_superadmin),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Resetar senha de usuário"""
|
||||||
|
user = db.query(User).filter(User.id == user_id).first()
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=404, detail="Usuário não encontrado")
|
||||||
|
|
||||||
|
user.password_hash = get_password_hash(data.new_password)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {"message": "Senha alterada com sucesso"}
|
||||||
56
backend/app/api/auth.py
Normal file
56
backend/app/api/auth.py
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.security import get_password_hash, verify_password, create_access_token
|
||||||
|
from app.models.user import User
|
||||||
|
from app.schemas.user import UserCreate, UserLogin, Token, UserResponse
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
@router.post("/register", response_model=Token)
|
||||||
|
def register(user: UserCreate, db: Session = Depends(get_db)):
|
||||||
|
if db.query(User).filter(User.email == user.email).first():
|
||||||
|
raise HTTPException(status_code=400, detail="Email already registered")
|
||||||
|
|
||||||
|
if db.query(User).filter(User.username == user.username).first():
|
||||||
|
raise HTTPException(status_code=400, detail="Username already taken")
|
||||||
|
|
||||||
|
db_user = User(
|
||||||
|
email=user.email,
|
||||||
|
username=user.username,
|
||||||
|
password_hash=get_password_hash(user.password),
|
||||||
|
full_name=user.full_name,
|
||||||
|
phone=user.phone,
|
||||||
|
is_verified=True # SEM verificação de email
|
||||||
|
)
|
||||||
|
|
||||||
|
db.add(db_user)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(db_user)
|
||||||
|
|
||||||
|
access_token = create_access_token(data={"sub": db_user.email})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"access_token": access_token,
|
||||||
|
"token_type": "bearer",
|
||||||
|
"user": UserResponse.from_orm(db_user)
|
||||||
|
}
|
||||||
|
|
||||||
|
@router.post("/login", response_model=Token)
|
||||||
|
def login(credentials: UserLogin, db: Session = Depends(get_db)):
|
||||||
|
user = db.query(User).filter(User.email == credentials.email).first()
|
||||||
|
|
||||||
|
if not user or not verify_password(credentials.password, user.password_hash):
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid credentials")
|
||||||
|
|
||||||
|
user.last_login_at = datetime.utcnow()
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
access_token = create_access_token(data={"sub": user.email})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"access_token": access_token,
|
||||||
|
"token_type": "bearer",
|
||||||
|
"user": UserResponse.from_orm(user)
|
||||||
|
}
|
||||||
155
backend/app/api/habits.py
Normal file
155
backend/app/api/habits.py
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from typing import List
|
||||||
|
from datetime import date, datetime
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.security import decode_access_token
|
||||||
|
from app.models.user import User
|
||||||
|
from app.models.habit import Habit, HabitCompletion
|
||||||
|
from app.schemas.habit import HabitCreate, HabitResponse, HabitCompletionCreate
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/habits", tags=["habits"])
|
||||||
|
security = HTTPBearer()
|
||||||
|
|
||||||
|
def get_current_user_id(credentials: HTTPAuthorizationCredentials = Depends(security)) -> str:
|
||||||
|
"""Extrai user_id do token JWT"""
|
||||||
|
token = credentials.credentials
|
||||||
|
payload = decode_access_token(token)
|
||||||
|
if not payload:
|
||||||
|
raise HTTPException(status_code=401, detail="Token inválido")
|
||||||
|
return payload.get("sub")
|
||||||
|
|
||||||
|
@router.post("/", response_model=HabitResponse, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_habit(
|
||||||
|
habit_data: HabitCreate,
|
||||||
|
user_id: str = Depends(get_current_user_id),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Criar novo hábito"""
|
||||||
|
new_habit = Habit(
|
||||||
|
user_id=user_id,
|
||||||
|
name=habit_data.name,
|
||||||
|
description=habit_data.description,
|
||||||
|
frequency_type=habit_data.frequency_type,
|
||||||
|
target_count=habit_data.target_count,
|
||||||
|
reminder_time=habit_data.reminder_time,
|
||||||
|
start_date=habit_data.start_date or date.today()
|
||||||
|
)
|
||||||
|
|
||||||
|
db.add(new_habit)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(new_habit)
|
||||||
|
|
||||||
|
return new_habit
|
||||||
|
|
||||||
|
@router.get("/", response_model=List[HabitResponse])
|
||||||
|
async def list_habits(
|
||||||
|
user_id: str = Depends(get_current_user_id),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Listar todos os hábitos do usuário"""
|
||||||
|
habits = db.query(Habit).filter(
|
||||||
|
Habit.user_id == user_id,
|
||||||
|
Habit.is_active == True
|
||||||
|
).all()
|
||||||
|
|
||||||
|
return habits
|
||||||
|
|
||||||
|
@router.post("/{habit_id}/complete", status_code=status.HTTP_201_CREATED)
|
||||||
|
async def complete_habit(
|
||||||
|
habit_id: str,
|
||||||
|
user_id: str = Depends(get_current_user_id),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Marcar hábito como completo para hoje"""
|
||||||
|
# Verificar se já completou hoje
|
||||||
|
today = date.today()
|
||||||
|
existing = db.query(HabitCompletion).filter(
|
||||||
|
HabitCompletion.habit_id == habit_id,
|
||||||
|
HabitCompletion.user_id == user_id,
|
||||||
|
HabitCompletion.completion_date == today
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
raise HTTPException(status_code=400, detail="Hábito já completado hoje")
|
||||||
|
|
||||||
|
# Criar completion
|
||||||
|
completion = HabitCompletion(
|
||||||
|
habit_id=habit_id,
|
||||||
|
user_id=user_id,
|
||||||
|
completion_date=today,
|
||||||
|
completion_time=datetime.now().time()
|
||||||
|
)
|
||||||
|
|
||||||
|
db.add(completion)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {"message": "Hábito completado!", "date": today}
|
||||||
|
|
||||||
|
@router.delete("/{habit_id}/complete")
|
||||||
|
async def uncomplete_habit(
|
||||||
|
habit_id: str,
|
||||||
|
user_id: str = Depends(get_current_user_id),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Desmarcar hábito de hoje"""
|
||||||
|
today = date.today()
|
||||||
|
completion = db.query(HabitCompletion).filter(
|
||||||
|
HabitCompletion.habit_id == habit_id,
|
||||||
|
HabitCompletion.user_id == user_id,
|
||||||
|
HabitCompletion.completion_date == today
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not completion:
|
||||||
|
raise HTTPException(status_code=404, detail="Completion não encontrado")
|
||||||
|
|
||||||
|
db.delete(completion)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {"message": "Completion removido"}
|
||||||
|
|
||||||
|
@router.get("/stats")
|
||||||
|
async def get_habit_stats(
|
||||||
|
user_id: str = Depends(get_current_user_id),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Estatísticas de hábitos"""
|
||||||
|
from sqlalchemy import func
|
||||||
|
|
||||||
|
total_habits = db.query(func.count(Habit.id)).filter(
|
||||||
|
Habit.user_id == user_id,
|
||||||
|
Habit.is_active == True
|
||||||
|
).scalar()
|
||||||
|
|
||||||
|
completed_today = db.query(func.count(HabitCompletion.id)).filter(
|
||||||
|
HabitCompletion.user_id == user_id,
|
||||||
|
HabitCompletion.completion_date == date.today()
|
||||||
|
).scalar()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total_habits": total_habits,
|
||||||
|
"completed_today": completed_today,
|
||||||
|
"pending_today": total_habits - completed_today
|
||||||
|
}
|
||||||
|
|
||||||
|
@router.delete("/{habit_id}")
|
||||||
|
async def delete_habit(
|
||||||
|
habit_id: str,
|
||||||
|
user_id: str = Depends(get_current_user_id),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Deletar hábito"""
|
||||||
|
habit = db.query(Habit).filter(
|
||||||
|
Habit.id == habit_id,
|
||||||
|
Habit.user_id == user_id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not habit:
|
||||||
|
raise HTTPException(status_code=404, detail="Hábito não encontrado")
|
||||||
|
|
||||||
|
db.delete(habit)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {"message": "Hábito deletado"}
|
||||||
169
backend/app/api/health.py
Normal file
169
backend/app/api/health.py
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from typing import List
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.security import decode_access_token
|
||||||
|
from app.models.health import HealthMetric
|
||||||
|
from app.schemas.health import HealthMetricCreate, HealthMetricResponse, HealthMetricUpdate
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
security = HTTPBearer()
|
||||||
|
|
||||||
|
def get_current_user_id(credentials: HTTPAuthorizationCredentials = Depends(security)) -> str:
|
||||||
|
"""Extrai user_id do token JWT"""
|
||||||
|
token = credentials.credentials
|
||||||
|
payload = decode_access_token(token)
|
||||||
|
if not payload:
|
||||||
|
raise HTTPException(status_code=401, detail="Token inválido")
|
||||||
|
return payload.get("sub")
|
||||||
|
|
||||||
|
@router.post("/", response_model=HealthMetricResponse, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_health_metric(
|
||||||
|
metric_data: HealthMetricCreate,
|
||||||
|
user_id: str = Depends(get_current_user_id),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Criar nova métrica de saúde"""
|
||||||
|
new_metric = HealthMetric(
|
||||||
|
user_id=user_id,
|
||||||
|
measurement_date=metric_data.measurement_date,
|
||||||
|
measurement_time=metric_data.measurement_time,
|
||||||
|
weight=metric_data.weight,
|
||||||
|
height=metric_data.height,
|
||||||
|
body_fat_percentage=metric_data.body_fat_percentage,
|
||||||
|
muscle_mass=metric_data.muscle_mass,
|
||||||
|
waist=metric_data.waist,
|
||||||
|
chest=metric_data.chest,
|
||||||
|
hips=metric_data.hips,
|
||||||
|
notes=metric_data.notes
|
||||||
|
)
|
||||||
|
|
||||||
|
db.add(new_metric)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(new_metric)
|
||||||
|
|
||||||
|
return new_metric
|
||||||
|
|
||||||
|
@router.get("/", response_model=List[HealthMetricResponse])
|
||||||
|
async def list_health_metrics(
|
||||||
|
user_id: str = Depends(get_current_user_id),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
limit: int = 100
|
||||||
|
):
|
||||||
|
"""Listar todas as métricas de saúde do usuário"""
|
||||||
|
metrics = db.query(HealthMetric).filter(
|
||||||
|
HealthMetric.user_id == user_id
|
||||||
|
).order_by(HealthMetric.measurement_date.desc()).limit(limit).all()
|
||||||
|
|
||||||
|
return metrics
|
||||||
|
|
||||||
|
@router.get("/latest", response_model=HealthMetricResponse)
|
||||||
|
async def get_latest_metric(
|
||||||
|
user_id: str = Depends(get_current_user_id),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Obter última métrica registrada"""
|
||||||
|
metric = db.query(HealthMetric).filter(
|
||||||
|
HealthMetric.user_id == user_id
|
||||||
|
).order_by(HealthMetric.measurement_date.desc()).first()
|
||||||
|
|
||||||
|
if not metric:
|
||||||
|
raise HTTPException(status_code=404, detail="Nenhuma métrica encontrada")
|
||||||
|
|
||||||
|
return metric
|
||||||
|
|
||||||
|
@router.get("/{metric_id}", response_model=HealthMetricResponse)
|
||||||
|
async def get_health_metric(
|
||||||
|
metric_id: str,
|
||||||
|
user_id: str = Depends(get_current_user_id),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Obter métrica específica"""
|
||||||
|
metric = db.query(HealthMetric).filter(
|
||||||
|
HealthMetric.id == metric_id,
|
||||||
|
HealthMetric.user_id == user_id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not metric:
|
||||||
|
raise HTTPException(status_code=404, detail="Métrica não encontrada")
|
||||||
|
|
||||||
|
return metric
|
||||||
|
|
||||||
|
@router.put("/{metric_id}", response_model=HealthMetricResponse)
|
||||||
|
async def update_health_metric(
|
||||||
|
metric_id: str,
|
||||||
|
metric_data: HealthMetricUpdate,
|
||||||
|
user_id: str = Depends(get_current_user_id),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Atualizar métrica de saúde"""
|
||||||
|
metric = db.query(HealthMetric).filter(
|
||||||
|
HealthMetric.id == metric_id,
|
||||||
|
HealthMetric.user_id == user_id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not metric:
|
||||||
|
raise HTTPException(status_code=404, detail="Métrica não encontrada")
|
||||||
|
|
||||||
|
# Atualizar campos
|
||||||
|
update_data = metric_data.dict(exclude_unset=True)
|
||||||
|
for field, value in update_data.items():
|
||||||
|
setattr(metric, field, value)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(metric)
|
||||||
|
|
||||||
|
return metric
|
||||||
|
|
||||||
|
@router.delete("/{metric_id}")
|
||||||
|
async def delete_health_metric(
|
||||||
|
metric_id: str,
|
||||||
|
user_id: str = Depends(get_current_user_id),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Deletar métrica de saúde"""
|
||||||
|
metric = db.query(HealthMetric).filter(
|
||||||
|
HealthMetric.id == metric_id,
|
||||||
|
HealthMetric.user_id == user_id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not metric:
|
||||||
|
raise HTTPException(status_code=404, detail="Métrica não encontrada")
|
||||||
|
|
||||||
|
db.delete(metric)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {"message": "Métrica deletada com sucesso"}
|
||||||
|
|
||||||
|
@router.get("/stats/summary")
|
||||||
|
async def get_health_stats(
|
||||||
|
user_id: str = Depends(get_current_user_id),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Estatísticas e resumo de saúde"""
|
||||||
|
latest = db.query(HealthMetric).filter(
|
||||||
|
HealthMetric.user_id == user_id
|
||||||
|
).order_by(HealthMetric.measurement_date.desc()).first()
|
||||||
|
|
||||||
|
# Pegar primeira e última medição para calcular diferença
|
||||||
|
first = db.query(HealthMetric).filter(
|
||||||
|
HealthMetric.user_id == user_id,
|
||||||
|
HealthMetric.weight.isnot(None)
|
||||||
|
).order_by(HealthMetric.measurement_date.asc()).first()
|
||||||
|
|
||||||
|
stats = {
|
||||||
|
"current_weight": float(latest.weight) if latest and latest.weight else None,
|
||||||
|
"current_height": float(latest.height) if latest and latest.height else None,
|
||||||
|
"current_body_fat": float(latest.body_fat_percentage) if latest and latest.body_fat_percentage else None,
|
||||||
|
"weight_change": None,
|
||||||
|
"total_measurements": db.query(HealthMetric).filter(HealthMetric.user_id == user_id).count()
|
||||||
|
}
|
||||||
|
|
||||||
|
if latest and first and latest.weight and first.weight:
|
||||||
|
stats["weight_change"] = float(latest.weight - first.weight)
|
||||||
|
|
||||||
|
return stats
|
||||||
|
|
||||||
93
backend/app/api/messages.py
Normal file
93
backend/app/api/messages.py
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from typing import Dict
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.services.message_service import MessageService
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/messages", tags=["messages"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/daily", response_model=Dict)
|
||||||
|
async def get_daily_message(
|
||||||
|
user_id: str, # TODO: Pegar do JWT token quando implementar autenticação
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Retorna a mensagem motivacional do dia para o usuário.
|
||||||
|
A mensagem é contextual baseada em:
|
||||||
|
- Streaks e conquistas
|
||||||
|
- Inatividade
|
||||||
|
- Lembretes
|
||||||
|
- Progresso
|
||||||
|
- Hora do dia
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
message_service = MessageService(db)
|
||||||
|
message = message_service.get_message_of_the_day(user_id)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message": message
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/click/{message_id}")
|
||||||
|
async def mark_message_clicked(
|
||||||
|
message_id: str,
|
||||||
|
user_id: str,
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Marca mensagem como clicada pelo usuário"""
|
||||||
|
from app.models.message import UserMessageLog
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
try:
|
||||||
|
log = db.query(UserMessageLog).filter(
|
||||||
|
UserMessageLog.id == message_id,
|
||||||
|
UserMessageLog.user_id == user_id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if log:
|
||||||
|
log.was_clicked = True
|
||||||
|
log.clicked_at = datetime.now()
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {"success": True, "message": "Click registrado"}
|
||||||
|
else:
|
||||||
|
raise HTTPException(status_code=404, detail="Mensagem não encontrada")
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/history")
|
||||||
|
async def get_message_history(
|
||||||
|
user_id: str,
|
||||||
|
limit: int = 10,
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Retorna histórico de mensagens do usuário"""
|
||||||
|
from app.models.message import UserMessageLog
|
||||||
|
|
||||||
|
try:
|
||||||
|
logs = db.query(UserMessageLog).filter(
|
||||||
|
UserMessageLog.user_id == user_id
|
||||||
|
).order_by(UserMessageLog.shown_at.desc()).limit(limit).all()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"id": str(log.id),
|
||||||
|
"message_text": log.message_text,
|
||||||
|
"message_type": log.message_type,
|
||||||
|
"shown_at": log.shown_at.isoformat(),
|
||||||
|
"was_clicked": log.was_clicked
|
||||||
|
}
|
||||||
|
for log in logs
|
||||||
|
]
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
218
backend/app/api/tasks.py
Normal file
218
backend/app/api/tasks.py
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from typing import List, Optional
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.security import decode_access_token
|
||||||
|
from app.models.task import Task
|
||||||
|
from app.schemas.task import TaskCreate, TaskResponse, TaskUpdate
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
security = HTTPBearer()
|
||||||
|
|
||||||
|
def get_current_user_id(credentials: HTTPAuthorizationCredentials = Depends(security)) -> str:
|
||||||
|
"""Extrai user_id do token JWT"""
|
||||||
|
token = credentials.credentials
|
||||||
|
payload = decode_access_token(token)
|
||||||
|
if not payload:
|
||||||
|
raise HTTPException(status_code=401, detail="Token inválido")
|
||||||
|
return payload.get("sub")
|
||||||
|
|
||||||
|
@router.post("/", response_model=TaskResponse, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_task(
|
||||||
|
task_data: TaskCreate,
|
||||||
|
user_id: str = Depends(get_current_user_id),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Criar nova tarefa"""
|
||||||
|
new_task = Task(
|
||||||
|
user_id=user_id,
|
||||||
|
title=task_data.title,
|
||||||
|
description=task_data.description,
|
||||||
|
priority=task_data.priority,
|
||||||
|
status=task_data.status,
|
||||||
|
due_date=task_data.due_date,
|
||||||
|
due_time=task_data.due_time,
|
||||||
|
category_id=task_data.category_id
|
||||||
|
)
|
||||||
|
|
||||||
|
db.add(new_task)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(new_task)
|
||||||
|
|
||||||
|
return new_task
|
||||||
|
|
||||||
|
@router.get("/", response_model=List[TaskResponse])
|
||||||
|
async def list_tasks(
|
||||||
|
user_id: str = Depends(get_current_user_id),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
status_filter: Optional[str] = None,
|
||||||
|
include_archived: bool = False
|
||||||
|
):
|
||||||
|
"""Listar todas as tarefas do usuário"""
|
||||||
|
query = db.query(Task).filter(Task.user_id == user_id)
|
||||||
|
|
||||||
|
if not include_archived:
|
||||||
|
query = query.filter(Task.is_archived == False)
|
||||||
|
|
||||||
|
if status_filter:
|
||||||
|
query = query.filter(Task.status == status_filter)
|
||||||
|
|
||||||
|
tasks = query.order_by(Task.due_date.asc()).all()
|
||||||
|
|
||||||
|
return tasks
|
||||||
|
|
||||||
|
@router.get("/today", response_model=List[TaskResponse])
|
||||||
|
async def get_today_tasks(
|
||||||
|
user_id: str = Depends(get_current_user_id),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Obter tarefas de hoje"""
|
||||||
|
today = date.today()
|
||||||
|
tasks = db.query(Task).filter(
|
||||||
|
Task.user_id == user_id,
|
||||||
|
Task.due_date == today,
|
||||||
|
Task.is_archived == False
|
||||||
|
).order_by(Task.due_time.asc()).all()
|
||||||
|
|
||||||
|
return tasks
|
||||||
|
|
||||||
|
@router.get("/{task_id}", response_model=TaskResponse)
|
||||||
|
async def get_task(
|
||||||
|
task_id: str,
|
||||||
|
user_id: str = Depends(get_current_user_id),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Obter tarefa específica"""
|
||||||
|
task = db.query(Task).filter(
|
||||||
|
Task.id == task_id,
|
||||||
|
Task.user_id == user_id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not task:
|
||||||
|
raise HTTPException(status_code=404, detail="Tarefa não encontrada")
|
||||||
|
|
||||||
|
return task
|
||||||
|
|
||||||
|
@router.put("/{task_id}", response_model=TaskResponse)
|
||||||
|
async def update_task(
|
||||||
|
task_id: str,
|
||||||
|
task_data: TaskUpdate,
|
||||||
|
user_id: str = Depends(get_current_user_id),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Atualizar tarefa"""
|
||||||
|
task = db.query(Task).filter(
|
||||||
|
Task.id == task_id,
|
||||||
|
Task.user_id == user_id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not task:
|
||||||
|
raise HTTPException(status_code=404, detail="Tarefa não encontrada")
|
||||||
|
|
||||||
|
# Atualizar campos
|
||||||
|
update_data = task_data.dict(exclude_unset=True)
|
||||||
|
for field, value in update_data.items():
|
||||||
|
setattr(task, field, value)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(task)
|
||||||
|
|
||||||
|
return task
|
||||||
|
|
||||||
|
@router.patch("/{task_id}/status")
|
||||||
|
async def update_task_status(
|
||||||
|
task_id: str,
|
||||||
|
status: str,
|
||||||
|
user_id: str = Depends(get_current_user_id),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Atualizar status da tarefa"""
|
||||||
|
task = db.query(Task).filter(
|
||||||
|
Task.id == task_id,
|
||||||
|
Task.user_id == user_id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not task:
|
||||||
|
raise HTTPException(status_code=404, detail="Tarefa não encontrada")
|
||||||
|
|
||||||
|
task.status = status
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {"message": "Status atualizado", "status": status}
|
||||||
|
|
||||||
|
@router.delete("/{task_id}")
|
||||||
|
async def delete_task(
|
||||||
|
task_id: str,
|
||||||
|
user_id: str = Depends(get_current_user_id),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Deletar tarefa"""
|
||||||
|
task = db.query(Task).filter(
|
||||||
|
Task.id == task_id,
|
||||||
|
Task.user_id == user_id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not task:
|
||||||
|
raise HTTPException(status_code=404, detail="Tarefa não encontrada")
|
||||||
|
|
||||||
|
db.delete(task)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {"message": "Tarefa deletada com sucesso"}
|
||||||
|
|
||||||
|
@router.get("/stats/summary")
|
||||||
|
async def get_task_stats(
|
||||||
|
user_id: str = Depends(get_current_user_id),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Estatísticas de tarefas"""
|
||||||
|
from sqlalchemy import func
|
||||||
|
|
||||||
|
total_tasks = db.query(func.count(Task.id)).filter(
|
||||||
|
Task.user_id == user_id,
|
||||||
|
Task.is_archived == False
|
||||||
|
).scalar()
|
||||||
|
|
||||||
|
pending = db.query(func.count(Task.id)).filter(
|
||||||
|
Task.user_id == user_id,
|
||||||
|
Task.status == "pending",
|
||||||
|
Task.is_archived == False
|
||||||
|
).scalar()
|
||||||
|
|
||||||
|
in_progress = db.query(func.count(Task.id)).filter(
|
||||||
|
Task.user_id == user_id,
|
||||||
|
Task.status == "in_progress",
|
||||||
|
Task.is_archived == False
|
||||||
|
).scalar()
|
||||||
|
|
||||||
|
completed = db.query(func.count(Task.id)).filter(
|
||||||
|
Task.user_id == user_id,
|
||||||
|
Task.status == "completed",
|
||||||
|
Task.is_archived == False
|
||||||
|
).scalar()
|
||||||
|
|
||||||
|
today = date.today()
|
||||||
|
today_tasks = db.query(func.count(Task.id)).filter(
|
||||||
|
Task.user_id == user_id,
|
||||||
|
Task.due_date == today,
|
||||||
|
Task.is_archived == False
|
||||||
|
).scalar()
|
||||||
|
|
||||||
|
today_completed = db.query(func.count(Task.id)).filter(
|
||||||
|
Task.user_id == user_id,
|
||||||
|
Task.due_date == today,
|
||||||
|
Task.status == "completed"
|
||||||
|
).scalar()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total_tasks": total_tasks,
|
||||||
|
"pending": pending,
|
||||||
|
"in_progress": in_progress,
|
||||||
|
"completed": completed,
|
||||||
|
"today_tasks": today_tasks,
|
||||||
|
"today_completed": today_completed
|
||||||
|
}
|
||||||
|
|
||||||
0
backend/app/core/__init__.py
Normal file
0
backend/app/core/__init__.py
Normal file
13
backend/app/core/config.py
Normal file
13
backend/app/core/config.py
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
from pydantic_settings import BaseSettings
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
DATABASE_URL: str
|
||||||
|
REDIS_URL: str
|
||||||
|
SECRET_KEY: str
|
||||||
|
ALGORITHM: str = "HS256"
|
||||||
|
ACCESS_TOKEN_EXPIRE_MINUTES: int = 10080
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
env_file = ".env"
|
||||||
|
|
||||||
|
settings = Settings()
|
||||||
21
backend/app/core/database.py
Normal file
21
backend/app/core/database.py
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.ext.declarative import declarative_base
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
import os
|
||||||
|
|
||||||
|
DATABASE_URL = os.getenv(
|
||||||
|
"DATABASE_URL",
|
||||||
|
"postgresql://vida180_user:vida180_strong_password_2024@postgres:5432/vida180_db"
|
||||||
|
)
|
||||||
|
|
||||||
|
engine = create_engine(DATABASE_URL)
|
||||||
|
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||||
|
|
||||||
|
Base = declarative_base()
|
||||||
|
|
||||||
|
def get_db():
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
41
backend/app/core/security.py
Normal file
41
backend/app/core/security.py
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Optional
|
||||||
|
from jose import JWTError, jwt
|
||||||
|
from argon2 import PasswordHasher
|
||||||
|
from argon2.exceptions import VerifyMismatchError
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
# Usar Argon2 ao invés de Bcrypt
|
||||||
|
ph = PasswordHasher()
|
||||||
|
|
||||||
|
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||||
|
"""Verifica se a senha corresponde ao hash"""
|
||||||
|
try:
|
||||||
|
ph.verify(hashed_password, plain_password)
|
||||||
|
return True
|
||||||
|
except VerifyMismatchError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get_password_hash(password: str) -> str:
|
||||||
|
"""Gera hash da senha"""
|
||||||
|
return ph.hash(password)
|
||||||
|
|
||||||
|
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
|
||||||
|
"""Cria token JWT"""
|
||||||
|
to_encode = data.copy()
|
||||||
|
if expires_delta:
|
||||||
|
expire = datetime.utcnow() + expires_delta
|
||||||
|
else:
|
||||||
|
expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||||
|
|
||||||
|
to_encode.update({"exp": expire})
|
||||||
|
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
|
||||||
|
return encoded_jwt
|
||||||
|
|
||||||
|
def decode_access_token(token: str) -> Optional[dict]:
|
||||||
|
"""Decodifica token JWT"""
|
||||||
|
try:
|
||||||
|
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
|
||||||
|
return payload
|
||||||
|
except JWTError:
|
||||||
|
return None
|
||||||
27
backend/app/main.py
Normal file
27
backend/app/main.py
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from app.core.database import engine, Base
|
||||||
|
from app.api import auth, habits, health, messages, admin, tasks
|
||||||
|
|
||||||
|
app = FastAPI(title="Vida180 API")
|
||||||
|
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["*"],
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
|
|
||||||
|
app.include_router(auth.router, prefix="/api/v1/auth", tags=["auth"])
|
||||||
|
app.include_router(habits.router, prefix="/api/v1/habits", tags=["habits"])
|
||||||
|
app.include_router(health.router, prefix="/api/v1/health", tags=["health"])
|
||||||
|
app.include_router(messages.router, prefix="/api/v1/messages", tags=["messages"])
|
||||||
|
app.include_router(admin.router, prefix="/api/v1/admin", tags=["admin"])
|
||||||
|
app.include_router(tasks.router, prefix="/api/v1/tasks", tags=["tasks"])
|
||||||
|
|
||||||
|
@app.get("/")
|
||||||
|
def root():
|
||||||
|
return {"message": "Vida180 API"}
|
||||||
5
backend/app/migrations/add_superadmin.sql
Normal file
5
backend/app/migrations/add_superadmin.sql
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
-- Adicionar campo is_superadmin
|
||||||
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS is_superadmin BOOLEAN DEFAULT FALSE;
|
||||||
|
|
||||||
|
-- Criar índice
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_users_superadmin ON users(is_superadmin);
|
||||||
1
backend/app/models/__init__.py
Normal file
1
backend/app/models/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# Models package
|
||||||
34
backend/app/models/habit.py
Normal file
34
backend/app/models/habit.py
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
from sqlalchemy import Column, String, Integer, Boolean, Date, Time, DateTime, ForeignKey, Text
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
class Habit(Base):
|
||||||
|
__tablename__ = "habits"
|
||||||
|
|
||||||
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
user_id = Column(UUID(as_uuid=True), ForeignKey('users.id', ondelete='CASCADE'), nullable=False, index=True)
|
||||||
|
name = Column(String(255), nullable=False)
|
||||||
|
description = Column(Text)
|
||||||
|
frequency_type = Column(String(50), default='daily')
|
||||||
|
target_count = Column(Integer, default=1)
|
||||||
|
reminder_time = Column(Time)
|
||||||
|
start_date = Column(Date, default=func.current_date())
|
||||||
|
end_date = Column(Date)
|
||||||
|
is_active = Column(Boolean, default=True)
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
|
class HabitCompletion(Base):
|
||||||
|
__tablename__ = "habit_completions"
|
||||||
|
|
||||||
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
habit_id = Column(UUID(as_uuid=True), ForeignKey('habits.id', ondelete='CASCADE'), nullable=False, index=True)
|
||||||
|
user_id = Column(UUID(as_uuid=True), ForeignKey('users.id', ondelete='CASCADE'), nullable=False, index=True)
|
||||||
|
completion_date = Column(Date, nullable=False, index=True)
|
||||||
|
completion_time = Column(Time)
|
||||||
|
notes = Column(Text)
|
||||||
|
quality_rating = Column(Integer)
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
21
backend/app/models/health.py
Normal file
21
backend/app/models/health.py
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
from sqlalchemy import Column, String, Date, Time, Numeric, ForeignKey
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
class HealthMetric(Base):
|
||||||
|
__tablename__ = "health_metrics"
|
||||||
|
|
||||||
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||||
|
measurement_date = Column(Date, nullable=False)
|
||||||
|
measurement_time = Column(Time)
|
||||||
|
weight = Column(Numeric(5, 2))
|
||||||
|
height = Column(Numeric(5, 2))
|
||||||
|
body_fat_percentage = Column(Numeric(4, 2))
|
||||||
|
muscle_mass = Column(Numeric(5, 2))
|
||||||
|
waist = Column(Numeric(5, 2))
|
||||||
|
chest = Column(Numeric(5, 2))
|
||||||
|
hips = Column(Numeric(5, 2))
|
||||||
|
notes = Column(String)
|
||||||
31
backend/app/models/message.py
Normal file
31
backend/app/models/message.py
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
from sqlalchemy import Column, String, Text, Integer, Boolean, DateTime, ForeignKey
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
class MotivationalMessage(Base):
|
||||||
|
__tablename__ = "motivational_messages"
|
||||||
|
|
||||||
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
message_type = Column(String(50), nullable=False)
|
||||||
|
trigger_condition = Column(String(100), nullable=False)
|
||||||
|
message_text = Column(Text, nullable=False)
|
||||||
|
icon = Column(String(10))
|
||||||
|
priority = Column(Integer, default=0)
|
||||||
|
is_active = Column(Boolean, default=True)
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
class UserMessageLog(Base):
|
||||||
|
__tablename__ = "user_messages_log"
|
||||||
|
|
||||||
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||||
|
message_id = Column(UUID(as_uuid=True), ForeignKey("motivational_messages.id", ondelete="SET NULL"))
|
||||||
|
message_text = Column(Text, nullable=False)
|
||||||
|
message_type = Column(String(50))
|
||||||
|
shown_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
was_clicked = Column(Boolean, default=False)
|
||||||
|
clicked_at = Column(DateTime(timezone=True))
|
||||||
16
backend/app/models/streak.py
Normal file
16
backend/app/models/streak.py
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
from sqlalchemy import Column, Integer, Date, ForeignKey
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
class Streak(Base):
|
||||||
|
__tablename__ = "streaks"
|
||||||
|
|
||||||
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
habit_id = Column(UUID(as_uuid=True), ForeignKey("habits.id", ondelete="CASCADE"), nullable=False)
|
||||||
|
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||||
|
current_streak = Column(Integer, default=0)
|
||||||
|
longest_streak = Column(Integer, default=0)
|
||||||
|
last_completion_date = Column(Date)
|
||||||
|
total_completions = Column(Integer, default=0)
|
||||||
21
backend/app/models/task.py
Normal file
21
backend/app/models/task.py
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
from sqlalchemy import Column, String, Date, Time, Boolean, ForeignKey
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
from datetime import datetime
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
class Task(Base):
|
||||||
|
__tablename__ = "tasks"
|
||||||
|
|
||||||
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||||
|
category_id = Column(UUID(as_uuid=True), nullable=True) # Removido FK para categories por enquanto
|
||||||
|
title = Column(String(255), nullable=False)
|
||||||
|
description = Column(String)
|
||||||
|
priority = Column(String(20), default='medium')
|
||||||
|
status = Column(String(20), default='pending')
|
||||||
|
due_date = Column(Date)
|
||||||
|
due_time = Column(Time)
|
||||||
|
is_archived = Column(Boolean, default=False)
|
||||||
25
backend/app/models/user.py
Normal file
25
backend/app/models/user.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
from sqlalchemy import Column, String, Boolean, DateTime
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
class User(Base):
|
||||||
|
__tablename__ = "users"
|
||||||
|
|
||||||
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
email = Column(String(255), unique=True, nullable=False, index=True)
|
||||||
|
username = Column(String(100), unique=True, nullable=False, index=True)
|
||||||
|
password_hash = Column(String(255), nullable=False)
|
||||||
|
full_name = Column(String(255))
|
||||||
|
phone = Column(String(20)) # NOVO
|
||||||
|
avatar_url = Column(String)
|
||||||
|
timezone = Column(String(50), default='America/Sao_Paulo')
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||||
|
last_login_at = Column(DateTime(timezone=True))
|
||||||
|
is_active = Column(Boolean, default=True)
|
||||||
|
is_verified = Column(Boolean, default=False)
|
||||||
|
is_superadmin = Column(Boolean, default=False)
|
||||||
|
verification_token = Column(String(255)) # NOVO - para confirmação de email
|
||||||
0
backend/app/schemas/__init__.py
Normal file
0
backend/app/schemas/__init__.py
Normal file
28
backend/app/schemas/admin.py
Normal file
28
backend/app/schemas/admin.py
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
from pydantic import BaseModel, EmailStr
|
||||||
|
from typing import Optional
|
||||||
|
from datetime import datetime
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
class UserListResponse(BaseModel):
|
||||||
|
id: uuid.UUID
|
||||||
|
email: EmailStr
|
||||||
|
username: str
|
||||||
|
full_name: Optional[str]
|
||||||
|
is_active: bool
|
||||||
|
is_verified: bool
|
||||||
|
is_superadmin: bool
|
||||||
|
created_at: datetime
|
||||||
|
last_login_at: Optional[datetime]
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
class UserUpdateRequest(BaseModel):
|
||||||
|
email: Optional[EmailStr] = None
|
||||||
|
username: Optional[str] = None
|
||||||
|
full_name: Optional[str] = None
|
||||||
|
is_active: Optional[bool] = None
|
||||||
|
is_verified: Optional[bool] = None
|
||||||
|
|
||||||
|
class PasswordChangeRequest(BaseModel):
|
||||||
|
new_password: str
|
||||||
28
backend/app/schemas/habit.py
Normal file
28
backend/app/schemas/habit.py
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Optional
|
||||||
|
from datetime import date, time
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
class HabitCreate(BaseModel):
|
||||||
|
name: str
|
||||||
|
description: Optional[str] = None
|
||||||
|
frequency_type: str = "daily"
|
||||||
|
target_count: int = 1
|
||||||
|
reminder_time: Optional[time] = None
|
||||||
|
start_date: Optional[date] = None
|
||||||
|
|
||||||
|
class HabitResponse(BaseModel):
|
||||||
|
id: uuid.UUID
|
||||||
|
name: str
|
||||||
|
description: Optional[str]
|
||||||
|
frequency_type: str
|
||||||
|
target_count: int
|
||||||
|
is_active: bool
|
||||||
|
start_date: date
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
class HabitCompletionCreate(BaseModel):
|
||||||
|
notes: Optional[str] = None
|
||||||
|
quality_rating: Optional[int] = None
|
||||||
47
backend/app/schemas/health.py
Normal file
47
backend/app/schemas/health.py
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Optional
|
||||||
|
from datetime import date, time
|
||||||
|
from decimal import Decimal
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
class HealthMetricCreate(BaseModel):
|
||||||
|
measurement_date: date
|
||||||
|
measurement_time: Optional[time] = None
|
||||||
|
weight: Optional[Decimal] = None
|
||||||
|
height: Optional[Decimal] = None
|
||||||
|
body_fat_percentage: Optional[Decimal] = None
|
||||||
|
muscle_mass: Optional[Decimal] = None
|
||||||
|
waist: Optional[Decimal] = None
|
||||||
|
chest: Optional[Decimal] = None
|
||||||
|
hips: Optional[Decimal] = None
|
||||||
|
notes: Optional[str] = None
|
||||||
|
|
||||||
|
class HealthMetricResponse(BaseModel):
|
||||||
|
id: uuid.UUID
|
||||||
|
user_id: uuid.UUID
|
||||||
|
measurement_date: date
|
||||||
|
measurement_time: Optional[time]
|
||||||
|
weight: Optional[Decimal]
|
||||||
|
height: Optional[Decimal]
|
||||||
|
body_fat_percentage: Optional[Decimal]
|
||||||
|
muscle_mass: Optional[Decimal]
|
||||||
|
waist: Optional[Decimal]
|
||||||
|
chest: Optional[Decimal]
|
||||||
|
hips: Optional[Decimal]
|
||||||
|
notes: Optional[str]
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
class HealthMetricUpdate(BaseModel):
|
||||||
|
measurement_date: Optional[date] = None
|
||||||
|
measurement_time: Optional[time] = None
|
||||||
|
weight: Optional[Decimal] = None
|
||||||
|
height: Optional[Decimal] = None
|
||||||
|
body_fat_percentage: Optional[Decimal] = None
|
||||||
|
muscle_mass: Optional[Decimal] = None
|
||||||
|
waist: Optional[Decimal] = None
|
||||||
|
chest: Optional[Decimal] = None
|
||||||
|
hips: Optional[Decimal] = None
|
||||||
|
notes: Optional[str] = None
|
||||||
|
|
||||||
39
backend/app/schemas/task.py
Normal file
39
backend/app/schemas/task.py
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Optional
|
||||||
|
from datetime import date, time
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
class TaskCreate(BaseModel):
|
||||||
|
title: str
|
||||||
|
description: Optional[str] = None
|
||||||
|
priority: str = "medium" # low, medium, high
|
||||||
|
status: str = "pending" # pending, in_progress, completed
|
||||||
|
due_date: Optional[date] = None
|
||||||
|
due_time: Optional[time] = None
|
||||||
|
category_id: Optional[uuid.UUID] = None
|
||||||
|
|
||||||
|
class TaskResponse(BaseModel):
|
||||||
|
id: uuid.UUID
|
||||||
|
user_id: uuid.UUID
|
||||||
|
title: str
|
||||||
|
description: Optional[str]
|
||||||
|
priority: str
|
||||||
|
status: str
|
||||||
|
due_date: Optional[date]
|
||||||
|
due_time: Optional[time]
|
||||||
|
is_archived: bool
|
||||||
|
category_id: Optional[uuid.UUID]
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
class TaskUpdate(BaseModel):
|
||||||
|
title: Optional[str] = None
|
||||||
|
description: Optional[str] = None
|
||||||
|
priority: Optional[str] = None
|
||||||
|
status: Optional[str] = None
|
||||||
|
due_date: Optional[date] = None
|
||||||
|
due_time: Optional[time] = None
|
||||||
|
is_archived: Optional[bool] = None
|
||||||
|
category_id: Optional[uuid.UUID] = None
|
||||||
|
|
||||||
53
backend/app/schemas/user.py
Normal file
53
backend/app/schemas/user.py
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
from pydantic import BaseModel, EmailStr, field_validator
|
||||||
|
from typing import Optional
|
||||||
|
import uuid
|
||||||
|
import re
|
||||||
|
|
||||||
|
class UserBase(BaseModel):
|
||||||
|
email: EmailStr
|
||||||
|
username: str
|
||||||
|
full_name: str
|
||||||
|
phone: str
|
||||||
|
|
||||||
|
class UserCreate(UserBase):
|
||||||
|
password: str
|
||||||
|
|
||||||
|
@field_validator('phone')
|
||||||
|
@classmethod
|
||||||
|
def validate_phone(cls, v):
|
||||||
|
# Remove tudo que não é número
|
||||||
|
phone = re.sub(r'\D', '', v)
|
||||||
|
if len(phone) < 10 or len(phone) > 11:
|
||||||
|
raise ValueError('Telefone deve ter 10 ou 11 dígitos')
|
||||||
|
return phone
|
||||||
|
|
||||||
|
@field_validator('full_name')
|
||||||
|
@classmethod
|
||||||
|
def validate_full_name(cls, v):
|
||||||
|
if not v or len(v.strip()) < 3:
|
||||||
|
raise ValueError('Nome completo deve ter no mínimo 3 caracteres')
|
||||||
|
return v.strip()
|
||||||
|
|
||||||
|
class UserLogin(BaseModel):
|
||||||
|
email: EmailStr
|
||||||
|
password: str
|
||||||
|
|
||||||
|
class UserResponse(BaseModel):
|
||||||
|
id: uuid.UUID
|
||||||
|
email: EmailStr
|
||||||
|
username: str
|
||||||
|
full_name: Optional[str]
|
||||||
|
phone: Optional[str]
|
||||||
|
is_superadmin: bool = False
|
||||||
|
is_verified: bool = False
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
class Token(BaseModel):
|
||||||
|
access_token: str
|
||||||
|
token_type: str = "bearer"
|
||||||
|
user: UserResponse
|
||||||
|
|
||||||
|
class TokenData(BaseModel):
|
||||||
|
sub: str
|
||||||
0
backend/app/services/__init__.py
Normal file
0
backend/app/services/__init__.py
Normal file
246
backend/app/services/email.py
Normal file
246
backend/app/services/email.py
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
from sendgrid import SendGridAPIClient
|
||||||
|
from sendgrid.helpers.mail import Mail, Email, To, Content
|
||||||
|
import logging
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
def send_verification_email(to_email: str, username: str, verification_token: str):
|
||||||
|
"""Envia email de verificação para novo usuário"""
|
||||||
|
|
||||||
|
verification_url = f"{settings.FRONTEND_URL}/verify-email/{verification_token}"
|
||||||
|
|
||||||
|
# Template do email
|
||||||
|
html_content = f"""
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<style>
|
||||||
|
body {{
|
||||||
|
font-family: 'Arial', sans-serif;
|
||||||
|
background-color: #f3f4f6;
|
||||||
|
padding: 20px;
|
||||||
|
}}
|
||||||
|
.container {{
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
background: white;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 40px;
|
||||||
|
box-shadow: 0 10px 30px rgba(0,0,0,0.1);
|
||||||
|
}}
|
||||||
|
.header {{
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}}
|
||||||
|
.logo {{
|
||||||
|
font-size: 2.5rem;
|
||||||
|
font-weight: 800;
|
||||||
|
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
margin: 0;
|
||||||
|
}}
|
||||||
|
.content {{
|
||||||
|
color: #374151;
|
||||||
|
line-height: 1.6;
|
||||||
|
}}
|
||||||
|
.button {{
|
||||||
|
display: inline-block;
|
||||||
|
margin: 30px 0;
|
||||||
|
padding: 15px 40px;
|
||||||
|
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
|
||||||
|
color: white !important;
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 16px;
|
||||||
|
}}
|
||||||
|
.footer {{
|
||||||
|
margin-top: 40px;
|
||||||
|
padding-top: 20px;
|
||||||
|
border-top: 2px solid #e5e7eb;
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 14px;
|
||||||
|
text-align: center;
|
||||||
|
}}
|
||||||
|
.highlight {{
|
||||||
|
color: #10b981;
|
||||||
|
font-weight: 700;
|
||||||
|
}}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header">
|
||||||
|
<h1 class="logo">🚀 VIDA180</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
|
<h2>Olá, <span class="highlight">{username}</span>! 👋</h2>
|
||||||
|
|
||||||
|
<p>Seja muito bem-vindo(a) ao <strong>Vida180</strong>! 🎉</p>
|
||||||
|
|
||||||
|
<p>Estamos muito felizes em ter você conosco nessa jornada de transformação!</p>
|
||||||
|
|
||||||
|
<p>Para ativar sua conta e começar a usar todas as funcionalidades, clique no botão abaixo:</p>
|
||||||
|
|
||||||
|
<div style="text-align: center;">
|
||||||
|
<a href="{verification_url}" class="button">
|
||||||
|
✅ Verificar Meu Email
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p style="color: #6b7280; font-size: 14px;">
|
||||||
|
Ou copie e cole este link no seu navegador:<br>
|
||||||
|
<a href="{verification_url}" style="color: #10b981;">{verification_url}</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>Após verificar seu email, você terá acesso completo a:</p>
|
||||||
|
<ul>
|
||||||
|
<li>🎯 <strong>Hábitos:</strong> Construa rotinas poderosas</li>
|
||||||
|
<li>📝 <strong>Tarefas:</strong> Organize seu dia</li>
|
||||||
|
<li>💪 <strong>Saúde:</strong> Acompanhe sua evolução física</li>
|
||||||
|
<li>📊 <strong>Progresso:</strong> Visualize sua transformação</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p><strong>A transformação acontece um dia de cada vez!</strong> 💪</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
<p>
|
||||||
|
Este email foi enviado porque você se cadastrou no Vida180.<br>
|
||||||
|
Se você não criou esta conta, pode ignorar este email.
|
||||||
|
</p>
|
||||||
|
<p style="margin-top: 20px;">
|
||||||
|
<strong>Vida180</strong> - Transforme sua vida, um dia de cada vez 🚀
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
message = Mail(
|
||||||
|
from_email=Email(settings.SENDGRID_FROM_EMAIL, settings.SENDGRID_FROM_NAME),
|
||||||
|
to_emails=To(to_email),
|
||||||
|
subject=f"🚀 Bem-vindo ao Vida180! Confirme seu email",
|
||||||
|
html_content=Content("text/html", html_content)
|
||||||
|
)
|
||||||
|
|
||||||
|
sg = SendGridAPIClient(settings.SENDGRID_API_KEY)
|
||||||
|
response = sg.send(message)
|
||||||
|
|
||||||
|
logger.info(f"Email enviado para {to_email} - Status: {response.status_code}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Erro ao enviar email para {to_email}: {str(e)}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def send_password_reset_email(to_email: str, username: str, reset_token: str):
|
||||||
|
"""Envia email de reset de senha"""
|
||||||
|
|
||||||
|
reset_url = f"{settings.FRONTEND_URL}/reset-password/{reset_token}"
|
||||||
|
|
||||||
|
html_content = f"""
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<style>
|
||||||
|
body {{
|
||||||
|
font-family: 'Arial', sans-serif;
|
||||||
|
background-color: #f3f4f6;
|
||||||
|
padding: 20px;
|
||||||
|
}}
|
||||||
|
.container {{
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
background: white;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 40px;
|
||||||
|
box-shadow: 0 10px 30px rgba(0,0,0,0.1);
|
||||||
|
}}
|
||||||
|
.header {{
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}}
|
||||||
|
.logo {{
|
||||||
|
font-size: 2.5rem;
|
||||||
|
font-weight: 800;
|
||||||
|
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
margin: 0;
|
||||||
|
}}
|
||||||
|
.button {{
|
||||||
|
display: inline-block;
|
||||||
|
margin: 30px 0;
|
||||||
|
padding: 15px 40px;
|
||||||
|
background: linear-gradient(135deg, #f97316 0%, #ea580c 100%);
|
||||||
|
color: white !important;
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}}
|
||||||
|
.alert {{
|
||||||
|
background: #fef3c7;
|
||||||
|
border-left: 4px solid #f59e0b;
|
||||||
|
padding: 15px;
|
||||||
|
margin: 20px 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
}}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header">
|
||||||
|
<h1 class="logo">🚀 VIDA180</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Olá, {username}! 👋</h2>
|
||||||
|
|
||||||
|
<p>Recebemos uma solicitação para redefinir sua senha.</p>
|
||||||
|
|
||||||
|
<div class="alert">
|
||||||
|
⚠️ Se você não solicitou a alteração de senha, ignore este email e sua senha permanecerá a mesma.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>Para criar uma nova senha, clique no botão abaixo:</p>
|
||||||
|
|
||||||
|
<div style="text-align: center;">
|
||||||
|
<a href="{reset_url}" class="button">
|
||||||
|
🔑 Redefinir Minha Senha
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p style="color: #6b7280; font-size: 14px;">
|
||||||
|
Este link expira em 24 horas.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div style="margin-top: 40px; padding-top: 20px; border-top: 2px solid #e5e7eb; color: #6b7280; font-size: 14px; text-align: center;">
|
||||||
|
<strong>Vida180</strong> - Transforme sua vida, um dia de cada vez 🚀
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
message = Mail(
|
||||||
|
from_email=Email(settings.SENDGRID_FROM_EMAIL, settings.SENDGRID_FROM_NAME),
|
||||||
|
to_emails=To(to_email),
|
||||||
|
subject="🔑 Redefinir senha - Vida180",
|
||||||
|
html_content=Content("text/html", html_content)
|
||||||
|
)
|
||||||
|
|
||||||
|
sg = SendGridAPIClient(settings.SENDGRID_API_KEY)
|
||||||
|
response = sg.send(message)
|
||||||
|
|
||||||
|
logger.info(f"Email de reset enviado para {to_email}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Erro ao enviar email de reset: {str(e)}")
|
||||||
|
return False
|
||||||
290
backend/app/services/message_service.py
Normal file
290
backend/app/services/message_service.py
Normal file
@@ -0,0 +1,290 @@
|
|||||||
|
from datetime import datetime, timedelta
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy import func, and_
|
||||||
|
from typing import Optional, Dict
|
||||||
|
import random
|
||||||
|
|
||||||
|
from app.models.message import MotivationalMessage, UserMessageLog
|
||||||
|
from app.models.habit import Habit, HabitCompletion
|
||||||
|
from app.models.task import Task
|
||||||
|
from app.models.health import HealthMetric
|
||||||
|
from app.models.streak import Streak
|
||||||
|
|
||||||
|
|
||||||
|
class MessageService:
|
||||||
|
"""Serviço para gerar mensagens contextuais inteligentes"""
|
||||||
|
|
||||||
|
def __init__(self, db: Session):
|
||||||
|
self.db = db
|
||||||
|
|
||||||
|
def get_message_of_the_day(self, user_id: str) -> Dict:
|
||||||
|
"""Retorna a mensagem mais relevante para o usuário"""
|
||||||
|
|
||||||
|
# 1. Verificar streak (prioridade alta)
|
||||||
|
streak_message = self._check_streak_achievements(user_id)
|
||||||
|
if streak_message:
|
||||||
|
self._log_message(user_id, streak_message)
|
||||||
|
return streak_message
|
||||||
|
|
||||||
|
# 2. Verificar inatividade (recuperação)
|
||||||
|
inactive_message = self._check_inactivity(user_id)
|
||||||
|
if inactive_message:
|
||||||
|
self._log_message(user_id, inactive_message)
|
||||||
|
return inactive_message
|
||||||
|
|
||||||
|
# 3. Verificar lembretes importantes
|
||||||
|
reminder_message = self._check_reminders(user_id)
|
||||||
|
if reminder_message:
|
||||||
|
self._log_message(user_id, reminder_message)
|
||||||
|
return reminder_message
|
||||||
|
|
||||||
|
# 4. Verificar progresso e milestones
|
||||||
|
progress_message = self._check_progress(user_id)
|
||||||
|
if progress_message:
|
||||||
|
self._log_message(user_id, progress_message)
|
||||||
|
return progress_message
|
||||||
|
|
||||||
|
# 5. Mensagem baseada em hora do dia
|
||||||
|
time_message = self._get_time_based_message()
|
||||||
|
if time_message:
|
||||||
|
self._log_message(user_id, time_message)
|
||||||
|
return time_message
|
||||||
|
|
||||||
|
# 6. Mensagem motivacional padrão
|
||||||
|
default_message = self._get_daily_motivation()
|
||||||
|
self._log_message(user_id, default_message)
|
||||||
|
return default_message
|
||||||
|
|
||||||
|
def _check_streak_achievements(self, user_id: str) -> Optional[Dict]:
|
||||||
|
"""Verifica conquistas de streak"""
|
||||||
|
# Buscar maior streak do usuário
|
||||||
|
max_streak = self.db.query(func.max(Streak.current_streak)).filter(
|
||||||
|
Streak.user_id == user_id
|
||||||
|
).scalar() or 0
|
||||||
|
|
||||||
|
# Definir condições de streak
|
||||||
|
conditions = [
|
||||||
|
(100, 'streak_100'),
|
||||||
|
(60, 'streak_60'),
|
||||||
|
(30, 'streak_30'),
|
||||||
|
(14, 'streak_14'),
|
||||||
|
(7, 'streak_7')
|
||||||
|
]
|
||||||
|
|
||||||
|
for days, condition in conditions:
|
||||||
|
if max_streak >= days:
|
||||||
|
# Verificar se já mostrou essa mensagem hoje
|
||||||
|
already_shown = self.db.query(UserMessageLog).filter(
|
||||||
|
and_(
|
||||||
|
UserMessageLog.user_id == user_id,
|
||||||
|
UserMessageLog.message_type == 'streak_achievement',
|
||||||
|
func.date(UserMessageLog.shown_at) == datetime.now().date()
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not already_shown:
|
||||||
|
message = self.db.query(MotivationalMessage).filter(
|
||||||
|
and_(
|
||||||
|
MotivationalMessage.message_type == 'streak_achievement',
|
||||||
|
MotivationalMessage.trigger_condition == condition,
|
||||||
|
MotivationalMessage.is_active == True
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if message:
|
||||||
|
return self._message_to_dict(message)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _check_inactivity(self, user_id: str) -> Optional[Dict]:
|
||||||
|
"""Verifica se o usuário está inativo"""
|
||||||
|
last_log = self.db.query(UserMessageLog).filter(
|
||||||
|
UserMessageLog.user_id == user_id
|
||||||
|
).order_by(UserMessageLog.shown_at.desc()).first()
|
||||||
|
|
||||||
|
if not last_log:
|
||||||
|
return None
|
||||||
|
|
||||||
|
days_inactive = (datetime.now() - last_log.shown_at).days
|
||||||
|
|
||||||
|
if days_inactive >= 14:
|
||||||
|
condition = 'inactive_14days'
|
||||||
|
elif days_inactive >= 7:
|
||||||
|
condition = 'inactive_7days'
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
|
message = self.db.query(MotivationalMessage).filter(
|
||||||
|
and_(
|
||||||
|
MotivationalMessage.message_type == 'recovery',
|
||||||
|
MotivationalMessage.trigger_condition == condition,
|
||||||
|
MotivationalMessage.is_active == True
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
|
||||||
|
return self._message_to_dict(message) if message else None
|
||||||
|
|
||||||
|
def _check_reminders(self, user_id: str) -> Optional[Dict]:
|
||||||
|
"""Verifica lembretes importantes"""
|
||||||
|
# Verificar última medição de saúde
|
||||||
|
last_metric = self.db.query(HealthMetric).filter(
|
||||||
|
HealthMetric.user_id == user_id
|
||||||
|
).order_by(HealthMetric.measurement_date.desc()).first()
|
||||||
|
|
||||||
|
if last_metric:
|
||||||
|
days_since_last = (datetime.now().date() - last_metric.measurement_date).days
|
||||||
|
|
||||||
|
if days_since_last >= 7:
|
||||||
|
condition = 'no_weight_7days'
|
||||||
|
elif days_since_last >= 3:
|
||||||
|
condition = 'no_weight_3days'
|
||||||
|
else:
|
||||||
|
condition = None
|
||||||
|
|
||||||
|
if condition:
|
||||||
|
message = self.db.query(MotivationalMessage).filter(
|
||||||
|
and_(
|
||||||
|
MotivationalMessage.message_type == 'reminder',
|
||||||
|
MotivationalMessage.trigger_condition == condition,
|
||||||
|
MotivationalMessage.is_active == True
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if message:
|
||||||
|
return self._message_to_dict(message)
|
||||||
|
|
||||||
|
# Verificar hábitos pendentes
|
||||||
|
pending_habits = self.db.query(Habit).filter(
|
||||||
|
and_(
|
||||||
|
Habit.user_id == user_id,
|
||||||
|
Habit.is_active == True,
|
||||||
|
~Habit.id.in_(
|
||||||
|
self.db.query(HabitCompletion.habit_id).filter(
|
||||||
|
and_(
|
||||||
|
HabitCompletion.user_id == user_id,
|
||||||
|
HabitCompletion.completion_date == datetime.now().date()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).count()
|
||||||
|
|
||||||
|
if pending_habits > 0:
|
||||||
|
message = self.db.query(MotivationalMessage).filter(
|
||||||
|
and_(
|
||||||
|
MotivationalMessage.message_type == 'reminder',
|
||||||
|
MotivationalMessage.trigger_condition == 'habits_pending',
|
||||||
|
MotivationalMessage.is_active == True
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if message:
|
||||||
|
msg_dict = self._message_to_dict(message)
|
||||||
|
msg_dict['message_text'] = f"🎯 Você tem {pending_habits} hábitos esperando por você hoje!"
|
||||||
|
return msg_dict
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _check_progress(self, user_id: str) -> Optional[Dict]:
|
||||||
|
"""Verifica progresso e milestones"""
|
||||||
|
# Calcular consistência do mês
|
||||||
|
first_day_month = datetime.now().replace(day=1).date()
|
||||||
|
|
||||||
|
total_habits_month = self.db.query(func.count(Habit.id)).filter(
|
||||||
|
and_(
|
||||||
|
Habit.user_id == user_id,
|
||||||
|
Habit.start_date <= datetime.now().date()
|
||||||
|
)
|
||||||
|
).scalar()
|
||||||
|
|
||||||
|
completed_habits_month = self.db.query(func.count(HabitCompletion.id)).filter(
|
||||||
|
and_(
|
||||||
|
HabitCompletion.user_id == user_id,
|
||||||
|
HabitCompletion.completion_date >= first_day_month
|
||||||
|
)
|
||||||
|
).scalar()
|
||||||
|
|
||||||
|
if total_habits_month > 0:
|
||||||
|
consistency = (completed_habits_month / total_habits_month) * 100
|
||||||
|
|
||||||
|
if consistency >= 80:
|
||||||
|
message = self.db.query(MotivationalMessage).filter(
|
||||||
|
and_(
|
||||||
|
MotivationalMessage.message_type == 'progress_milestone',
|
||||||
|
MotivationalMessage.trigger_condition == 'consistency_80',
|
||||||
|
MotivationalMessage.is_active == True
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if message:
|
||||||
|
msg_dict = self._message_to_dict(message)
|
||||||
|
msg_dict['message_text'] = f"💎 {int(consistency)}% de consistência esse mês! Top 10% dos usuários!"
|
||||||
|
return msg_dict
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _get_time_based_message(self) -> Optional[Dict]:
|
||||||
|
"""Retorna mensagem baseada na hora do dia"""
|
||||||
|
hour = datetime.now().hour
|
||||||
|
|
||||||
|
if 5 <= hour < 12:
|
||||||
|
condition = 'morning'
|
||||||
|
elif 12 <= hour < 18:
|
||||||
|
condition = 'afternoon'
|
||||||
|
elif 18 <= hour < 22:
|
||||||
|
condition = 'evening'
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
|
message = self.db.query(MotivationalMessage).filter(
|
||||||
|
and_(
|
||||||
|
MotivationalMessage.message_type == 'time_based',
|
||||||
|
MotivationalMessage.trigger_condition == condition,
|
||||||
|
MotivationalMessage.is_active == True
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
|
||||||
|
return self._message_to_dict(message) if message else None
|
||||||
|
|
||||||
|
def _get_daily_motivation(self) -> Dict:
|
||||||
|
"""Retorna uma mensagem motivacional aleatória"""
|
||||||
|
messages = self.db.query(MotivationalMessage).filter(
|
||||||
|
and_(
|
||||||
|
MotivationalMessage.message_type == 'daily_motivation',
|
||||||
|
MotivationalMessage.is_active == True
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
if messages:
|
||||||
|
message = random.choice(messages)
|
||||||
|
return self._message_to_dict(message)
|
||||||
|
|
||||||
|
# Fallback
|
||||||
|
return {
|
||||||
|
'id': None,
|
||||||
|
'message_text': '💪 A transformação acontece um dia de cada vez. Esse dia é hoje!',
|
||||||
|
'icon': '💪',
|
||||||
|
'message_type': 'daily_motivation',
|
||||||
|
'priority': 1
|
||||||
|
}
|
||||||
|
|
||||||
|
def _message_to_dict(self, message: MotivationalMessage) -> Dict:
|
||||||
|
"""Converte mensagem para dicionário"""
|
||||||
|
return {
|
||||||
|
'id': str(message.id) if message.id else None,
|
||||||
|
'message_text': message.message_text,
|
||||||
|
'icon': message.icon,
|
||||||
|
'message_type': message.message_type,
|
||||||
|
'priority': message.priority
|
||||||
|
}
|
||||||
|
|
||||||
|
def _log_message(self, user_id: str, message: Dict):
|
||||||
|
"""Registra mensagem exibida no histórico"""
|
||||||
|
log = UserMessageLog(
|
||||||
|
user_id=user_id,
|
||||||
|
message_id=message.get('id'),
|
||||||
|
message_text=message['message_text'],
|
||||||
|
message_type=message['message_type']
|
||||||
|
)
|
||||||
|
self.db.add(log)
|
||||||
|
self.db.commit()
|
||||||
247
backend/init-db.sql
Normal file
247
backend/init-db.sql
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
-- Vida180 Database Schema
|
||||||
|
-- Created: 2024
|
||||||
|
|
||||||
|
-- Enable UUID extension
|
||||||
|
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||||
|
|
||||||
|
-- Users table
|
||||||
|
CREATE TABLE users (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
email VARCHAR(255) UNIQUE NOT NULL,
|
||||||
|
username VARCHAR(100) UNIQUE NOT NULL,
|
||||||
|
password_hash VARCHAR(255) NOT NULL,
|
||||||
|
full_name VARCHAR(255),
|
||||||
|
avatar_url TEXT,
|
||||||
|
timezone VARCHAR(50) DEFAULT 'America/Sao_Paulo',
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
last_login_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
is_verified BOOLEAN DEFAULT FALSE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Categories table
|
||||||
|
CREATE TABLE categories (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
color VARCHAR(7),
|
||||||
|
icon VARCHAR(50),
|
||||||
|
display_order INTEGER DEFAULT 0,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(user_id, name)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Tasks table
|
||||||
|
CREATE TABLE tasks (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
category_id UUID REFERENCES categories(id) ON DELETE SET NULL,
|
||||||
|
title VARCHAR(255) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
priority VARCHAR(20) DEFAULT 'medium',
|
||||||
|
status VARCHAR(20) DEFAULT 'pending',
|
||||||
|
due_date DATE,
|
||||||
|
due_time TIME,
|
||||||
|
completed_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
is_archived BOOLEAN DEFAULT FALSE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Habits table
|
||||||
|
CREATE TABLE habits (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
category_id UUID REFERENCES categories(id) ON DELETE SET NULL,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
frequency_type VARCHAR(20) NOT NULL,
|
||||||
|
target_count INTEGER DEFAULT 1,
|
||||||
|
reminder_time TIME,
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
start_date DATE NOT NULL,
|
||||||
|
end_date DATE,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Habit completions table
|
||||||
|
CREATE TABLE habit_completions (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
habit_id UUID NOT NULL REFERENCES habits(id) ON DELETE CASCADE,
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
completion_date DATE NOT NULL,
|
||||||
|
completion_time TIME,
|
||||||
|
notes TEXT,
|
||||||
|
quality_rating INTEGER,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(habit_id, completion_date)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Streaks table
|
||||||
|
CREATE TABLE streaks (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
habit_id UUID NOT NULL REFERENCES habits(id) ON DELETE CASCADE,
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
current_streak INTEGER DEFAULT 0,
|
||||||
|
longest_streak INTEGER DEFAULT 0,
|
||||||
|
last_completion_date DATE,
|
||||||
|
total_completions INTEGER DEFAULT 0,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(habit_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Health metrics table
|
||||||
|
CREATE TABLE health_metrics (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
measurement_date DATE NOT NULL,
|
||||||
|
measurement_time TIME DEFAULT CURRENT_TIME,
|
||||||
|
weight DECIMAL(5,2),
|
||||||
|
height DECIMAL(5,2),
|
||||||
|
body_fat_percentage DECIMAL(4,2),
|
||||||
|
muscle_mass DECIMAL(5,2),
|
||||||
|
waist DECIMAL(5,2),
|
||||||
|
chest DECIMAL(5,2),
|
||||||
|
hips DECIMAL(5,2),
|
||||||
|
notes TEXT,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(user_id, measurement_date)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Health goals table
|
||||||
|
CREATE TABLE health_goals (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
goal_type VARCHAR(50) NOT NULL,
|
||||||
|
metric_type VARCHAR(50) NOT NULL,
|
||||||
|
starting_value DECIMAL(5,2) NOT NULL,
|
||||||
|
target_value DECIMAL(5,2) NOT NULL,
|
||||||
|
current_value DECIMAL(5,2),
|
||||||
|
start_date DATE NOT NULL,
|
||||||
|
target_date DATE,
|
||||||
|
achieved_date DATE,
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
notes TEXT,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Create indexes
|
||||||
|
CREATE INDEX idx_categories_user_id ON categories(user_id);
|
||||||
|
CREATE INDEX idx_tasks_user_id ON tasks(user_id);
|
||||||
|
CREATE INDEX idx_tasks_status ON tasks(status);
|
||||||
|
CREATE INDEX idx_habits_user_id ON habits(user_id);
|
||||||
|
CREATE INDEX idx_health_metrics_user_id ON health_metrics(user_id);
|
||||||
|
CREATE INDEX idx_health_metrics_date ON health_metrics(measurement_date);
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- MENSAGENS MOTIVACIONAIS
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
CREATE TABLE motivational_messages (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
message_type VARCHAR(50) NOT NULL,
|
||||||
|
trigger_condition VARCHAR(100) NOT NULL,
|
||||||
|
message_text TEXT NOT NULL,
|
||||||
|
icon VARCHAR(10),
|
||||||
|
priority INTEGER DEFAULT 0,
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT valid_message_type CHECK (message_type IN (
|
||||||
|
'streak_achievement',
|
||||||
|
'reminder',
|
||||||
|
'progress_milestone',
|
||||||
|
'time_based',
|
||||||
|
'special_milestone',
|
||||||
|
'recovery',
|
||||||
|
'daily_motivation'
|
||||||
|
))
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Índices
|
||||||
|
CREATE INDEX idx_messages_type ON motivational_messages(message_type);
|
||||||
|
CREATE INDEX idx_messages_active ON motivational_messages(is_active);
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- USER MESSAGES LOG (histórico de mensagens vistas)
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
CREATE TABLE user_messages_log (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
message_id UUID REFERENCES motivational_messages(id) ON DELETE SET NULL,
|
||||||
|
message_text TEXT NOT NULL,
|
||||||
|
message_type VARCHAR(50),
|
||||||
|
shown_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
was_clicked BOOLEAN DEFAULT FALSE,
|
||||||
|
clicked_at TIMESTAMP WITH TIME ZONE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_user_messages_user_id ON user_messages_log(user_id);
|
||||||
|
CREATE INDEX idx_user_messages_shown_at ON user_messages_log(shown_at);
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- INSERIR MENSAGENS PADRÃO
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
-- Mensagens de Streak
|
||||||
|
INSERT INTO motivational_messages (message_type, trigger_condition, message_text, icon, priority) VALUES
|
||||||
|
('streak_achievement', 'streak_7', '🔥 7 dias de sequência! Essa disciplina vai te levar longe!', '🔥', 10),
|
||||||
|
('streak_achievement', 'streak_14', '💪 14 dias! Você está construindo algo sólido aqui!', '💪', 15),
|
||||||
|
('streak_achievement', 'streak_30', '🏆 30 DIAS! A maioria desiste antes. Você não!', '🏆', 20),
|
||||||
|
('streak_achievement', 'streak_60', '⚡ 60 dias de transformação real! Continue nessa pegada!', '⚡', 25),
|
||||||
|
('streak_achievement', 'streak_100', '👑 100 DIAS! Você oficialmente virou o jogo da sua vida!', '👑', 30),
|
||||||
|
('streak_achievement', 'streak_365', '🎉 1 ANO! Você é lenda! Prova viva de transformação!', '🎉', 50);
|
||||||
|
|
||||||
|
-- Lembretes
|
||||||
|
INSERT INTO motivational_messages (message_type, trigger_condition, message_text, icon, priority) VALUES
|
||||||
|
('reminder', 'no_weight_3days', '📊 Faz 3 dias sem registrar peso. Que tal medir hoje?', '📊', 5),
|
||||||
|
('reminder', 'no_weight_7days', '⚠️ 7 dias sem medição. Continue acompanhando sua evolução!', '⚠️', 8),
|
||||||
|
('reminder', 'habits_pending', '🎯 Você tem hábitos esperando por você hoje!', '🎯', 7),
|
||||||
|
('reminder', 'tasks_pending', '✅ Tarefas do dia te aguardam. Vamos começar?', '✅', 6);
|
||||||
|
|
||||||
|
-- Progresso
|
||||||
|
INSERT INTO motivational_messages (message_type, trigger_condition, message_text, icon, priority) VALUES
|
||||||
|
('progress_milestone', 'consistency_80', '💎 80% de consistência esse mês! Top 10% dos usuários!', '💎', 12),
|
||||||
|
('progress_milestone', 'weight_loss_5kg', '🎊 5kg perdidos! A estrada está aparecendo, continue!', '🎊', 15),
|
||||||
|
('progress_milestone', 'weight_loss_10kg', '🌟 10kg! Transformação visível! Que orgulho!', '🌟', 20),
|
||||||
|
('progress_milestone', 'first_goal_achieved', '🏅 Primeiro objetivo alcançado! Defina o próximo!', '🏅', 18);
|
||||||
|
|
||||||
|
-- Mensagens por Hora do Dia
|
||||||
|
INSERT INTO motivational_messages (message_type, trigger_condition, message_text, icon, priority) VALUES
|
||||||
|
('time_based', 'morning', '🌅 Bom dia! Que tal começar o dia marcando suas metas?', '🌅', 3),
|
||||||
|
('time_based', 'afternoon', '☀️ Boa tarde! Como está o progresso de hoje?', '☀️', 2),
|
||||||
|
('time_based', 'evening', '🌙 Boa noite! Antes de dormir, registre o que conquistou hoje.', '🌙', 3),
|
||||||
|
('time_based', 'monday', '💼 Segunda-feira! Nova semana, novas vitórias! Vamos lá!', '💼', 4);
|
||||||
|
|
||||||
|
-- Milestones Especiais
|
||||||
|
INSERT INTO motivational_messages (message_type, trigger_condition, message_text, icon, priority) VALUES
|
||||||
|
('special_milestone', 'first_habit_complete', '⭐ Primeiro hábito completado! Esse é o início!', '⭐', 8),
|
||||||
|
('special_milestone', 'all_tasks_complete', '✨ TODAS as tarefas do dia completas! Incrível!', '✨', 10),
|
||||||
|
('special_milestone', 'perfect_week', '🔥 Semana perfeita! 7 dias sem falhas! Imparável!', '🔥', 15);
|
||||||
|
|
||||||
|
-- Recuperação
|
||||||
|
INSERT INTO motivational_messages (message_type, trigger_condition, message_text, icon, priority) VALUES
|
||||||
|
('recovery', 'inactive_7days', '👋 Sentimos sua falta! A vida acontece, mas você pode retomar AGORA!', '👋', 9),
|
||||||
|
('recovery', 'inactive_14days', '💙 14 dias offline. Que tal voltar hoje? Estamos aqui pra você!', '💙', 11),
|
||||||
|
('recovery', 'streak_broken', '🔄 Quebrou a sequência? Normal! Recomeçar é parte da jornada.', '🔄', 7);
|
||||||
|
|
||||||
|
-- Motivacionais Diárias (genéricas)
|
||||||
|
INSERT INTO motivational_messages (message_type, trigger_condition, message_text, icon, priority) VALUES
|
||||||
|
('daily_motivation', 'default_1', '💪 A transformação acontece um dia de cada vez. Esse dia é hoje!', '💪', 1),
|
||||||
|
('daily_motivation', 'default_2', '🚀 Cada pequena vitória hoje constrói o grande resultado de amanhã.', '🚀', 1),
|
||||||
|
('daily_motivation', 'default_3', '⚡ Você não precisa ser perfeito. Só precisa ser consistente.', '⚡', 1),
|
||||||
|
('daily_motivation', 'default_4', '🎯 O segredo do sucesso? Aparecer todos os dias.', '🎯', 1),
|
||||||
|
('daily_motivation', 'default_5', '🌟 Seu eu do futuro vai agradecer pelo que você faz hoje.', '🌟', 1),
|
||||||
|
('daily_motivation', 'default_6', '💎 Pequenos passos, grandes mudanças. Continue!', '💎', 1),
|
||||||
|
('daily_motivation', 'default_7', '🔥 A disciplina de hoje é a liberdade de amanhã.', '🔥', 1);
|
||||||
|
|
||||||
|
COMMENT ON TABLE motivational_messages IS 'Banco de mensagens motivacionais contextuais';
|
||||||
|
COMMENT ON TABLE user_messages_log IS 'Histórico de mensagens exibidas para cada usuário';
|
||||||
17
backend/requirements.txt
Normal file
17
backend/requirements.txt
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
fastapi==0.104.1
|
||||||
|
uvicorn[standard]==0.24.0
|
||||||
|
sqlalchemy==2.0.23
|
||||||
|
psycopg2-binary==2.9.9
|
||||||
|
redis==5.0.1
|
||||||
|
python-dotenv==1.0.0
|
||||||
|
pydantic==2.5.0
|
||||||
|
pydantic-settings==2.1.0
|
||||||
|
email-validator==2.1.0
|
||||||
|
|
||||||
|
# Authentication
|
||||||
|
python-jose[cryptography]==3.3.0
|
||||||
|
argon2-cffi==23.1.0
|
||||||
|
python-multipart==0.0.6
|
||||||
|
|
||||||
|
# Email
|
||||||
|
sendgrid==6.11.0
|
||||||
59
docker-compose.yml
Normal file
59
docker-compose.yml
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:15-alpine
|
||||||
|
container_name: vida180_postgres
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: vida180_db
|
||||||
|
POSTGRES_USER: vida180_user
|
||||||
|
POSTGRES_PASSWORD: vida180_password
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
- ./backend/init.sql:/docker-entrypoint-initdb.d/init.sql
|
||||||
|
ports:
|
||||||
|
- "5433:5432"
|
||||||
|
networks:
|
||||||
|
- vida180_network
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
container_name: vida180_redis
|
||||||
|
ports:
|
||||||
|
- "6380:6379"
|
||||||
|
networks:
|
||||||
|
- vida180_network
|
||||||
|
|
||||||
|
backend:
|
||||||
|
build: ./backend
|
||||||
|
container_name: vida180_backend
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgresql://vida180_user:vida180_password@postgres:5432/vida180_db
|
||||||
|
REDIS_URL: redis://redis:6379
|
||||||
|
SECRET_KEY: your-secret-key-change-this-in-production
|
||||||
|
ALGORITHM: HS256
|
||||||
|
ACCESS_TOKEN_EXPIRE_MINUTES: 10080
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
depends_on:
|
||||||
|
- postgres
|
||||||
|
- redis
|
||||||
|
networks:
|
||||||
|
- vida180_network
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build: ./frontend
|
||||||
|
container_name: vida180_frontend
|
||||||
|
ports:
|
||||||
|
- "3200:3000"
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
networks:
|
||||||
|
- vida180_network
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
vida180_network:
|
||||||
|
driver: bridge
|
||||||
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"
|
||||||
|
]
|
||||||
|
}
|
||||||
25
nginx/conf.d/vida180-temp.conf
Normal file
25
nginx/conf.d/vida180-temp.conf
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name vida180.com.br www.vida180.com.br;
|
||||||
|
|
||||||
|
location /.well-known/acme-challenge/ {
|
||||||
|
root /var/www/certbot;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Frontend
|
||||||
|
location / {
|
||||||
|
proxy_pass http://31.97.26.2:3200;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection 'upgrade';
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_cache_bypass $http_upgrade;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Backend API
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://31.97.26.2:8000/api/;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
}
|
||||||
|
}
|
||||||
74
nginx/conf.d/vida180.conf
Normal file
74
nginx/conf.d/vida180.conf
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
# HTTP - Redireciona para HTTPS
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name vida180.com.br www.vida180.com.br;
|
||||||
|
|
||||||
|
location /.well-known/acme-challenge/ {
|
||||||
|
root /var/www/certbot;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# HTTPS - Frontend
|
||||||
|
server {
|
||||||
|
listen 443 ssl http2;
|
||||||
|
server_name vida180.com.br www.vida180.com.br;
|
||||||
|
|
||||||
|
# SSL certificados (serão gerados pelo certbot)
|
||||||
|
ssl_certificate /etc/letsencrypt/live/vida180.com.br/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/vida180.com.br/privkey.pem;
|
||||||
|
|
||||||
|
# SSL configuração
|
||||||
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
|
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||||
|
ssl_prefer_server_ciphers on;
|
||||||
|
ssl_session_cache shared:SSL:10m;
|
||||||
|
ssl_session_timeout 10m;
|
||||||
|
|
||||||
|
# Security headers
|
||||||
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header X-XSS-Protection "1; mode=block" always;
|
||||||
|
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||||
|
|
||||||
|
# Frontend (React)
|
||||||
|
location / {
|
||||||
|
proxy_pass http://frontend:3000;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection 'upgrade';
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_cache_bypass $http_upgrade;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Backend API
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://backend:8000/api/;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Backend Docs
|
||||||
|
location /docs {
|
||||||
|
proxy_pass http://backend:8000/docs;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /redoc {
|
||||||
|
proxy_pass http://backend:8000/redoc;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
}
|
||||||
|
}
|
||||||
37
nginx/nginx.conf
Normal file
37
nginx/nginx.conf
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
user nginx;
|
||||||
|
worker_processes auto;
|
||||||
|
error_log /var/log/nginx/error.log warn;
|
||||||
|
pid /var/run/nginx.pid;
|
||||||
|
|
||||||
|
events {
|
||||||
|
worker_connections 1024;
|
||||||
|
}
|
||||||
|
|
||||||
|
http {
|
||||||
|
include /etc/nginx/mime.types;
|
||||||
|
default_type application/octet-stream;
|
||||||
|
|
||||||
|
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
|
||||||
|
'$status $body_bytes_sent "$http_referer" '
|
||||||
|
'"$http_user_agent" "$http_x_forwarded_for"';
|
||||||
|
|
||||||
|
access_log /var/log/nginx/access.log main;
|
||||||
|
|
||||||
|
sendfile on;
|
||||||
|
tcp_nopush on;
|
||||||
|
tcp_nodelay on;
|
||||||
|
keepalive_timeout 65;
|
||||||
|
types_hash_max_size 2048;
|
||||||
|
client_max_body_size 20M;
|
||||||
|
|
||||||
|
gzip on;
|
||||||
|
gzip_vary on;
|
||||||
|
gzip_proxied any;
|
||||||
|
gzip_comp_level 6;
|
||||||
|
gzip_types text/plain text/css text/xml text/javascript
|
||||||
|
application/json application/javascript application/xml+rss
|
||||||
|
application/rss+xml font/truetype font/opentype
|
||||||
|
application/vnd.ms-fontobject image/svg+xml;
|
||||||
|
|
||||||
|
include /etc/nginx/conf.d/*.conf;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user