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