Compare commits
5 Commits
vue
...
ecf237173c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ecf237173c | ||
|
|
746c34879e | ||
|
|
f599424ae9 | ||
|
|
f3a05d4bb4 | ||
|
|
eb43b636c0 |
36
.gitignore
vendored
36
.gitignore
vendored
@@ -1,36 +0,0 @@
|
|||||||
# Logs
|
|
||||||
logs
|
|
||||||
*.log
|
|
||||||
npm-debug.log*
|
|
||||||
yarn-debug.log*
|
|
||||||
yarn-error.log*
|
|
||||||
pnpm-debug.log*
|
|
||||||
lerna-debug.log*
|
|
||||||
|
|
||||||
node_modules
|
|
||||||
.DS_Store
|
|
||||||
dist
|
|
||||||
dist-ssr
|
|
||||||
coverage
|
|
||||||
*.local
|
|
||||||
venv
|
|
||||||
# Editor directories and files
|
|
||||||
.vscode/*
|
|
||||||
!.vscode/extensions.json
|
|
||||||
.idea
|
|
||||||
*.suo
|
|
||||||
*.ntvs*
|
|
||||||
*.njsproj
|
|
||||||
*.sln
|
|
||||||
*.sw?
|
|
||||||
|
|
||||||
*.tsbuildinfo
|
|
||||||
|
|
||||||
.eslintcache
|
|
||||||
|
|
||||||
# Cypress
|
|
||||||
/cypress/videos/
|
|
||||||
/cypress/screenshots/
|
|
||||||
|
|
||||||
# Vitest
|
|
||||||
__screenshots__/
|
|
||||||
3
.vscode/extensions.json
vendored
3
.vscode/extensions.json
vendored
@@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
"recommendations": ["Vue.volar"]
|
|
||||||
}
|
|
||||||
788
app.py
788
app.py
@@ -1,788 +0,0 @@
|
|||||||
from flask import Flask, request, jsonify
|
|
||||||
from flask_cors import CORS
|
|
||||||
import sqlite3
|
|
||||||
import matplotlib
|
|
||||||
matplotlib.use('Agg')
|
|
||||||
import matplotlib.pyplot as plt
|
|
||||||
import base64
|
|
||||||
import io
|
|
||||||
import bcrypt
|
|
||||||
import os
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
import json
|
|
||||||
|
|
||||||
|
|
||||||
app = Flask(__name__)
|
|
||||||
CORS(app)
|
|
||||||
|
|
||||||
# Configuração do SQLite
|
|
||||||
DB_PATH = 'ctrlcash.db'
|
|
||||||
|
|
||||||
# Funções do Banco de Dados
|
|
||||||
def init_db():
|
|
||||||
"""Inicializa o banco de dados e cria as tabelas"""
|
|
||||||
conn = sqlite3.connect(DB_PATH)
|
|
||||||
cursor = conn.cursor()
|
|
||||||
|
|
||||||
# Tabela de usuários
|
|
||||||
cursor.execute('''
|
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
email TEXT UNIQUE NOT NULL,
|
|
||||||
password TEXT NOT NULL,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
monthly_income_goal REAL DEFAULT 5000,
|
|
||||||
monthly_expense_limit REAL DEFAULT 2500,
|
|
||||||
currency TEXT DEFAULT 'BRL',
|
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
||||||
)
|
|
||||||
''')
|
|
||||||
|
|
||||||
# Tabela de categorias
|
|
||||||
cursor.execute('''
|
|
||||||
CREATE TABLE IF NOT EXISTS categories (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
user_id INTEGER NOT NULL,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
type TEXT NOT NULL,
|
|
||||||
color TEXT NOT NULL,
|
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
FOREIGN KEY (user_id) REFERENCES users (id),
|
|
||||||
UNIQUE(user_id, name, type)
|
|
||||||
)
|
|
||||||
''')
|
|
||||||
|
|
||||||
# Tabela de transações
|
|
||||||
cursor.execute('''
|
|
||||||
CREATE TABLE IF NOT EXISTS transactions (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
user_id INTEGER NOT NULL,
|
|
||||||
amount REAL NOT NULL,
|
|
||||||
description TEXT NOT NULL,
|
|
||||||
category TEXT NOT NULL,
|
|
||||||
type TEXT NOT NULL,
|
|
||||||
date DATE NOT NULL,
|
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
FOREIGN KEY (user_id) REFERENCES users (id)
|
|
||||||
)
|
|
||||||
''')
|
|
||||||
|
|
||||||
conn.commit()
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
def get_db_connection():
|
|
||||||
"""Cria uma conexão com o banco de dados"""
|
|
||||||
conn = sqlite3.connect(DB_PATH)
|
|
||||||
conn.row_factory = sqlite3.Row # Para retornar dicionários
|
|
||||||
return conn
|
|
||||||
|
|
||||||
# Categorias padrão
|
|
||||||
default_categories = [
|
|
||||||
{"name": "Alimentação", "type": "expense", "color": "#FF6B6B"},
|
|
||||||
{"name": "Transporte", "type": "expense", "color": "#4ECDC4"},
|
|
||||||
{"name": "Moradia", "type": "expense", "color": "#45B7D1"},
|
|
||||||
{"name": "Saúde", "type": "expense", "color": "#96CEB4"},
|
|
||||||
{"name": "Educação", "type": "expense", "color": "#FFEAA7"},
|
|
||||||
{"name": "Lazer", "type": "expense", "color": "#DDA0DD"},
|
|
||||||
{"name": "Salário", "type": "income", "color": "#98D8C8"},
|
|
||||||
{"name": "Investimentos", "type": "income", "color": "#F7DC6F"},
|
|
||||||
{"name": "Freelance", "type": "income", "color": "#BB8FCE"},
|
|
||||||
{"name": "Outros", "type": "income", "color": "#95a5a6"},
|
|
||||||
{"name": "Outros", "type": "expense", "color": "#95a5a6"}
|
|
||||||
]
|
|
||||||
|
|
||||||
# Funções auxiliares
|
|
||||||
def hash_password(password):
|
|
||||||
return bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
|
|
||||||
|
|
||||||
def check_password(password, hashed):
|
|
||||||
return bcrypt.checkpw(password.encode('utf-8'), hashed.encode('utf-8'))
|
|
||||||
|
|
||||||
def init_default_categories(user_id):
|
|
||||||
"""Insere categorias padrão para um usuário"""
|
|
||||||
conn = get_db_connection()
|
|
||||||
cursor = conn.cursor()
|
|
||||||
|
|
||||||
for cat in default_categories:
|
|
||||||
cursor.execute('''
|
|
||||||
INSERT OR IGNORE INTO categories (user_id, name, type, color)
|
|
||||||
VALUES (?, ?, ?, ?)
|
|
||||||
''', (user_id, cat['name'], cat['type'], cat['color']))
|
|
||||||
|
|
||||||
conn.commit()
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# ROTAS DE AUTENTICAÇÃO
|
|
||||||
@app.route('/api/auth/register', methods=['POST'])
|
|
||||||
def register():
|
|
||||||
try:
|
|
||||||
data = request.get_json()
|
|
||||||
email = data.get('email', '').strip().lower()
|
|
||||||
password = data.get('password', '')
|
|
||||||
name = data.get('name', '').strip()
|
|
||||||
|
|
||||||
if not email or not password or not name:
|
|
||||||
return jsonify({"error": "Todos os campos são obrigatórios"}), 400
|
|
||||||
|
|
||||||
if len(password) < 6:
|
|
||||||
return jsonify({"error": "A senha deve ter pelo menos 6 caracteres"}), 400
|
|
||||||
|
|
||||||
conn = get_db_connection()
|
|
||||||
cursor = conn.cursor()
|
|
||||||
|
|
||||||
# Verificar se usuário já existe
|
|
||||||
cursor.execute('SELECT id FROM users WHERE email = ?', (email,))
|
|
||||||
if cursor.fetchone():
|
|
||||||
conn.close()
|
|
||||||
return jsonify({"error": "Email já cadastrado"}), 400
|
|
||||||
|
|
||||||
# Criar usuário
|
|
||||||
cursor.execute('''
|
|
||||||
INSERT INTO users (email, password, name)
|
|
||||||
VALUES (?, ?, ?)
|
|
||||||
''', (email, hash_password(password), name))
|
|
||||||
|
|
||||||
user_id = cursor.lastrowid
|
|
||||||
|
|
||||||
conn.commit()
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
# Inicializar categorias e dados mock
|
|
||||||
init_default_categories(user_id)
|
|
||||||
|
|
||||||
return jsonify({
|
|
||||||
"message": "Usuário criado com sucesso",
|
|
||||||
"user": {
|
|
||||||
"id": user_id,
|
|
||||||
"email": email,
|
|
||||||
"name": name,
|
|
||||||
"profile": {
|
|
||||||
"monthly_income_goal": 5000,
|
|
||||||
"monthly_expense_limit": 2500,
|
|
||||||
"currency": "BRL"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}), 201
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Erro no registro: {e}")
|
|
||||||
return jsonify({"error": "Erro interno do servidor"}), 500
|
|
||||||
|
|
||||||
@app.route('/api/auth/login', methods=['POST'])
|
|
||||||
def login():
|
|
||||||
try:
|
|
||||||
data = request.get_json()
|
|
||||||
email = data.get('email', '').strip().lower()
|
|
||||||
password = data.get('password', '')
|
|
||||||
|
|
||||||
if not email or not password:
|
|
||||||
return jsonify({"error": "Email e senha são obrigatórios"}), 400
|
|
||||||
|
|
||||||
conn = get_db_connection()
|
|
||||||
cursor = conn.cursor()
|
|
||||||
|
|
||||||
cursor.execute('''
|
|
||||||
SELECT id, email, password, name, monthly_income_goal, monthly_expense_limit, currency
|
|
||||||
FROM users WHERE email = ?
|
|
||||||
''', (email,))
|
|
||||||
|
|
||||||
user = cursor.fetchone()
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
if not user or not check_password(password, user['password']):
|
|
||||||
return jsonify({"error": "Credenciais inválidas"}), 401
|
|
||||||
|
|
||||||
return jsonify({
|
|
||||||
"message": "Login realizado com sucesso",
|
|
||||||
"user": {
|
|
||||||
"id": user['id'],
|
|
||||||
"email": user['email'],
|
|
||||||
"name": user['name'],
|
|
||||||
"profile": {
|
|
||||||
"monthly_income_goal": user['monthly_income_goal'],
|
|
||||||
"monthly_expense_limit": user['monthly_expense_limit'],
|
|
||||||
"currency": user['currency']
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}), 200
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Erro no login: {e}")
|
|
||||||
return jsonify({"error": "Erro interno do servidor"}), 500
|
|
||||||
|
|
||||||
# ROTAS DE TRANSAÇÕES
|
|
||||||
@app.route('/api/transactions', methods=['GET'])
|
|
||||||
def get_transactions():
|
|
||||||
try:
|
|
||||||
user_id = request.args.get('user_id')
|
|
||||||
if not user_id:
|
|
||||||
return jsonify({"transactions": []}), 200
|
|
||||||
|
|
||||||
conn = get_db_connection()
|
|
||||||
cursor = conn.cursor()
|
|
||||||
|
|
||||||
cursor.execute('''
|
|
||||||
SELECT id, user_id, amount, description, category, type, date
|
|
||||||
FROM transactions
|
|
||||||
WHERE user_id = ?
|
|
||||||
ORDER BY date DESC
|
|
||||||
''', (user_id,))
|
|
||||||
|
|
||||||
transactions = []
|
|
||||||
for row in cursor.fetchall():
|
|
||||||
transactions.append({
|
|
||||||
"id": row['id'],
|
|
||||||
"user_id": row['user_id'],
|
|
||||||
"amount": row['amount'],
|
|
||||||
"description": row['description'],
|
|
||||||
"category": row['category'],
|
|
||||||
"type": row['type'],
|
|
||||||
"date": row['date']
|
|
||||||
})
|
|
||||||
|
|
||||||
conn.close()
|
|
||||||
return jsonify({"transactions": transactions}), 200
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Erro ao buscar transações: {e}")
|
|
||||||
return jsonify({"error": "Erro ao buscar transações"}), 500
|
|
||||||
|
|
||||||
@app.route('/api/transactions', methods=['POST'])
|
|
||||||
def add_transaction():
|
|
||||||
try:
|
|
||||||
data = request.get_json()
|
|
||||||
user_id = data.get('user_id')
|
|
||||||
|
|
||||||
if not user_id:
|
|
||||||
return jsonify({"error": "User ID é obrigatório"}), 400
|
|
||||||
|
|
||||||
# Validar dados
|
|
||||||
amount = float(data.get('amount', 0))
|
|
||||||
description = data.get('description', '').strip()
|
|
||||||
category = data.get('category', 'Outros')
|
|
||||||
transaction_type = data.get('type', 'expense')
|
|
||||||
date_str = data.get('date', '')
|
|
||||||
|
|
||||||
if amount <= 0:
|
|
||||||
return jsonify({"error": "Valor deve ser maior que zero"}), 400
|
|
||||||
|
|
||||||
if not description:
|
|
||||||
return jsonify({"error": "Descrição é obrigatória"}), 400
|
|
||||||
|
|
||||||
# ✅ CORREÇÃO: Usar a data EXATA que veio do frontend
|
|
||||||
# Não converter para datetime, usar como string mesmo
|
|
||||||
if not date_str:
|
|
||||||
date_str = datetime.now().strftime('%Y-%m-%d')
|
|
||||||
|
|
||||||
# Validar formato da data (YYYY-MM-DD)
|
|
||||||
try:
|
|
||||||
datetime.strptime(date_str, '%Y-%m-%d')
|
|
||||||
except ValueError:
|
|
||||||
return jsonify({"error": "Formato de data inválido. Use YYYY-MM-DD"}), 400
|
|
||||||
|
|
||||||
conn = get_db_connection()
|
|
||||||
cursor = conn.cursor()
|
|
||||||
|
|
||||||
# ✅ CORREÇÃO: Inserir a data EXATA como string
|
|
||||||
cursor.execute('''
|
|
||||||
INSERT INTO transactions (user_id, amount, description, category, type, date)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?)
|
|
||||||
''', (user_id, amount, description, category, transaction_type, date_str))
|
|
||||||
|
|
||||||
transaction_id = cursor.lastrowid
|
|
||||||
|
|
||||||
# Buscar transação criada
|
|
||||||
cursor.execute('SELECT * FROM transactions WHERE id = ?', (transaction_id,))
|
|
||||||
new_transaction = cursor.fetchone()
|
|
||||||
|
|
||||||
conn.commit()
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
return jsonify({
|
|
||||||
"message": "Transação adicionada com sucesso",
|
|
||||||
"transaction": {
|
|
||||||
"id": new_transaction['id'],
|
|
||||||
"user_id": new_transaction['user_id'],
|
|
||||||
"amount": new_transaction['amount'],
|
|
||||||
"description": new_transaction['description'],
|
|
||||||
"category": new_transaction['category'],
|
|
||||||
"type": new_transaction['type'],
|
|
||||||
"date": new_transaction['date'] # ✅ Já vem correto do banco
|
|
||||||
}
|
|
||||||
}), 201
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Erro ao adicionar transação: {e}")
|
|
||||||
return jsonify({"error": "Erro ao adicionar transação"}), 500
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Erro ao adicionar transação: {e}")
|
|
||||||
return jsonify({"error": "Erro ao adicionar transação"}), 500
|
|
||||||
|
|
||||||
@app.route('/api/transactions/<int:transaction_id>', methods=['DELETE'])
|
|
||||||
def delete_transaction(transaction_id):
|
|
||||||
try:
|
|
||||||
user_id = request.args.get('user_id')
|
|
||||||
if not user_id:
|
|
||||||
return jsonify({"error": "User ID é obrigatório"}), 400
|
|
||||||
|
|
||||||
conn = get_db_connection()
|
|
||||||
cursor = conn.cursor()
|
|
||||||
|
|
||||||
cursor.execute('''
|
|
||||||
DELETE FROM transactions
|
|
||||||
WHERE id = ? AND user_id = ?
|
|
||||||
''', (transaction_id, user_id))
|
|
||||||
|
|
||||||
if cursor.rowcount == 0:
|
|
||||||
conn.close()
|
|
||||||
return jsonify({"error": "Transação não encontrada"}), 404
|
|
||||||
|
|
||||||
conn.commit()
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
return jsonify({"message": "Transação deletada com sucesso"}), 200
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Erro ao deletar transação: {e}")
|
|
||||||
return jsonify({"error": "Erro ao deletar transação"}), 500
|
|
||||||
|
|
||||||
# ROTAS DE CATEGORIAS
|
|
||||||
@app.route('/api/categories', methods=['GET'])
|
|
||||||
def get_categories():
|
|
||||||
try:
|
|
||||||
user_id = request.args.get('user_id')
|
|
||||||
|
|
||||||
if user_id:
|
|
||||||
# Buscar categorias do usuário
|
|
||||||
conn = get_db_connection()
|
|
||||||
cursor = conn.cursor()
|
|
||||||
|
|
||||||
cursor.execute('''
|
|
||||||
SELECT id, user_id, name, type, color
|
|
||||||
FROM categories
|
|
||||||
WHERE user_id = ?
|
|
||||||
''', (user_id,))
|
|
||||||
|
|
||||||
categories = []
|
|
||||||
for row in cursor.fetchall():
|
|
||||||
categories.append({
|
|
||||||
"id": row['id'],
|
|
||||||
"user_id": row['user_id'],
|
|
||||||
"name": row['name'],
|
|
||||||
"type": row['type'],
|
|
||||||
"color": row['color']
|
|
||||||
})
|
|
||||||
|
|
||||||
conn.close()
|
|
||||||
else:
|
|
||||||
# Retornar categorias padrão
|
|
||||||
categories = [{"id": i, **cat} for i, cat in enumerate(default_categories)]
|
|
||||||
|
|
||||||
return jsonify({"categories": categories}), 200
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Erro ao buscar categorias: {e}")
|
|
||||||
return jsonify({"categories": default_categories}), 200
|
|
||||||
|
|
||||||
@app.route('/api/categories', methods=['POST'])
|
|
||||||
def add_category():
|
|
||||||
try:
|
|
||||||
data = request.get_json()
|
|
||||||
user_id = data.get('user_id')
|
|
||||||
name = data.get('name', '').strip()
|
|
||||||
category_type = data.get('type')
|
|
||||||
color = data.get('color', '#6c757d')
|
|
||||||
|
|
||||||
if not user_id:
|
|
||||||
return jsonify({"error": "User ID é obrigatório"}), 400
|
|
||||||
|
|
||||||
if not name:
|
|
||||||
return jsonify({"error": "Nome da categoria é obrigatório"}), 400
|
|
||||||
|
|
||||||
if not category_type or category_type not in ['income', 'expense']:
|
|
||||||
return jsonify({"error": "Tipo de categoria inválido"}), 400
|
|
||||||
|
|
||||||
conn = get_db_connection()
|
|
||||||
cursor = conn.cursor()
|
|
||||||
|
|
||||||
# Verificar se categoria já existe
|
|
||||||
cursor.execute('''
|
|
||||||
SELECT id FROM categories
|
|
||||||
WHERE user_id = ? AND name = ? AND type = ?
|
|
||||||
''', (user_id, name, category_type))
|
|
||||||
|
|
||||||
if cursor.fetchone():
|
|
||||||
conn.close()
|
|
||||||
return jsonify({"error": "Categoria já existe"}), 400
|
|
||||||
|
|
||||||
# Inserir nova categoria
|
|
||||||
cursor.execute('''
|
|
||||||
INSERT INTO categories (user_id, name, type, color)
|
|
||||||
VALUES (?, ?, ?, ?)
|
|
||||||
''', (user_id, name, category_type, color))
|
|
||||||
|
|
||||||
category_id = cursor.lastrowid
|
|
||||||
|
|
||||||
conn.commit()
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
return jsonify({
|
|
||||||
"message": "Categoria adicionada com sucesso",
|
|
||||||
"category": {
|
|
||||||
"id": category_id,
|
|
||||||
"user_id": user_id,
|
|
||||||
"name": name,
|
|
||||||
"type": category_type,
|
|
||||||
"color": color
|
|
||||||
}
|
|
||||||
}), 201
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Erro ao adicionar categoria: {e}")
|
|
||||||
return jsonify({"error": "Erro ao adicionar categoria"}), 500
|
|
||||||
|
|
||||||
# ROTAS DO DASHBOARD
|
|
||||||
@app.route('/api/dashboard/summary', methods=['GET'])
|
|
||||||
def get_dashboard_summary():
|
|
||||||
try:
|
|
||||||
user_id = request.args.get('user_id')
|
|
||||||
if not user_id:
|
|
||||||
return jsonify({
|
|
||||||
"total_income": 0,
|
|
||||||
"total_expenses": 0,
|
|
||||||
"balance": 0,
|
|
||||||
"recent_transactions": []
|
|
||||||
}), 200
|
|
||||||
|
|
||||||
conn = get_db_connection()
|
|
||||||
cursor = conn.cursor()
|
|
||||||
|
|
||||||
# Calcular totais do mês atual
|
|
||||||
first_day_of_month = datetime.now().replace(day=1).strftime('%Y-%m-%d')
|
|
||||||
|
|
||||||
# Receitas do mês
|
|
||||||
cursor.execute('''
|
|
||||||
SELECT COALESCE(SUM(amount), 0) as total
|
|
||||||
FROM transactions
|
|
||||||
WHERE user_id = ? AND type = 'income' AND date >= ?
|
|
||||||
''', (user_id, first_day_of_month))
|
|
||||||
|
|
||||||
total_income = cursor.fetchone()['total'] or 0
|
|
||||||
|
|
||||||
# Despesas do mês
|
|
||||||
cursor.execute('''
|
|
||||||
SELECT COALESCE(SUM(amount), 0) as total
|
|
||||||
FROM transactions
|
|
||||||
WHERE user_id = ? AND type = 'expense' AND date >= ?
|
|
||||||
''', (user_id, first_day_of_month))
|
|
||||||
|
|
||||||
total_expenses = cursor.fetchone()['total'] or 0
|
|
||||||
balance = total_income - total_expenses
|
|
||||||
|
|
||||||
# Últimas 5 transações
|
|
||||||
cursor.execute('''
|
|
||||||
SELECT id, user_id, amount, description, category, type, date
|
|
||||||
FROM transactions
|
|
||||||
WHERE user_id = ?
|
|
||||||
ORDER BY date DESC
|
|
||||||
LIMIT 5
|
|
||||||
''', (user_id,))
|
|
||||||
|
|
||||||
recent_transactions = []
|
|
||||||
for row in cursor.fetchall():
|
|
||||||
recent_transactions.append({
|
|
||||||
"id": row['id'],
|
|
||||||
"user_id": row['user_id'],
|
|
||||||
"amount": row['amount'],
|
|
||||||
"description": row['description'],
|
|
||||||
"category": row['category'],
|
|
||||||
"type": row['type'],
|
|
||||||
"date": row['date']
|
|
||||||
})
|
|
||||||
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
return jsonify({
|
|
||||||
"total_income": round(total_income, 2),
|
|
||||||
"total_expenses": round(total_expenses, 2),
|
|
||||||
"balance": round(balance, 2),
|
|
||||||
"recent_transactions": recent_transactions
|
|
||||||
}), 200
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Erro no dashboard: {e}")
|
|
||||||
return jsonify({
|
|
||||||
"total_income": 0,
|
|
||||||
"total_expenses": 0,
|
|
||||||
"balance": 0,
|
|
||||||
"recent_transactions": []
|
|
||||||
}), 200
|
|
||||||
|
|
||||||
@app.route('/api/dashboard/chart')
|
|
||||||
def get_chart():
|
|
||||||
try:
|
|
||||||
user_id = request.args.get('user_id')
|
|
||||||
chart_type = request.args.get('type', 'monthly')
|
|
||||||
|
|
||||||
if not user_id:
|
|
||||||
return jsonify({"error": "User ID é obrigatório"}), 400
|
|
||||||
|
|
||||||
conn = get_db_connection()
|
|
||||||
cursor = conn.cursor()
|
|
||||||
|
|
||||||
plt.figure(figsize=(10, 6))
|
|
||||||
|
|
||||||
if chart_type == 'monthly':
|
|
||||||
# Últimos 6 meses
|
|
||||||
six_months_ago = (datetime.now() - timedelta(days=180)).strftime('%Y-%m-%d')
|
|
||||||
|
|
||||||
cursor.execute('''
|
|
||||||
SELECT
|
|
||||||
strftime('%Y-%m', date) as month,
|
|
||||||
SUM(CASE WHEN type = 'income' THEN amount ELSE 0 END) as income,
|
|
||||||
SUM(CASE WHEN type = 'expense' THEN amount ELSE 0 END) as expenses
|
|
||||||
FROM transactions
|
|
||||||
WHERE user_id = ? AND date >= ?
|
|
||||||
GROUP BY strftime('%Y-%m', date)
|
|
||||||
ORDER BY month DESC
|
|
||||||
LIMIT 6
|
|
||||||
''', (user_id, six_months_ago))
|
|
||||||
|
|
||||||
result = cursor.fetchall()
|
|
||||||
|
|
||||||
if result:
|
|
||||||
months = [row['month'] for row in result]
|
|
||||||
income = [row['income'] for row in result]
|
|
||||||
expenses = [row['expenses'] for row in result]
|
|
||||||
|
|
||||||
# Reverter para ordem cronológica
|
|
||||||
months.reverse()
|
|
||||||
income.reverse()
|
|
||||||
expenses.reverse()
|
|
||||||
|
|
||||||
x = range(len(months))
|
|
||||||
width = 0.35
|
|
||||||
|
|
||||||
plt.bar([i - width/2 for i in x], income, width, label='Receitas', color='#28a745')
|
|
||||||
plt.bar([i + width/2 for i in x], expenses, width, label='Despesas', color='#dc3545')
|
|
||||||
plt.xlabel('Mês')
|
|
||||||
plt.ylabel('Valor (R$)')
|
|
||||||
plt.title('Receitas vs Despesas - Últimos 6 Meses')
|
|
||||||
plt.xticks(x, months, rotation=45)
|
|
||||||
plt.legend()
|
|
||||||
plt.tight_layout()
|
|
||||||
else:
|
|
||||||
plt.text(0.5, 0.5, 'Sem dados para exibir',
|
|
||||||
ha='center', va='center', transform=plt.gca().transAxes)
|
|
||||||
|
|
||||||
elif chart_type == 'categories':
|
|
||||||
# Gastos por categoria (últimos 30 dias)
|
|
||||||
thirty_days_ago = (datetime.now() - timedelta(days=30)).strftime('%Y-%m-%d')
|
|
||||||
|
|
||||||
cursor.execute('''
|
|
||||||
SELECT category, SUM(amount) as total
|
|
||||||
FROM transactions
|
|
||||||
WHERE user_id = ? AND type = 'expense' AND date >= ?
|
|
||||||
GROUP BY category
|
|
||||||
''', (user_id, thirty_days_ago))
|
|
||||||
|
|
||||||
result = cursor.fetchall()
|
|
||||||
|
|
||||||
if result:
|
|
||||||
categories = [row['category'] for row in result]
|
|
||||||
amounts = [row['total'] for row in result]
|
|
||||||
|
|
||||||
plt.pie(amounts, labels=categories, autopct='%1.1f%%', startangle=90)
|
|
||||||
plt.title('Distribuição de Gastos por Categoria (Últimos 30 dias)')
|
|
||||||
else:
|
|
||||||
plt.text(0.5, 0.5, 'Sem dados de gastos',
|
|
||||||
ha='center', va='center', transform=plt.gca().transAxes)
|
|
||||||
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
# Converter para base64
|
|
||||||
buffer = io.BytesIO()
|
|
||||||
plt.savefig(buffer, format='png', dpi=100, bbox_inches='tight')
|
|
||||||
buffer.seek(0)
|
|
||||||
image_base64 = base64.b64encode(buffer.getvalue()).decode()
|
|
||||||
plt.close()
|
|
||||||
|
|
||||||
return jsonify({
|
|
||||||
"chart": f"data:image/png;base64,{image_base64}"
|
|
||||||
}), 200
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Erro ao gerar gráfico: {e}")
|
|
||||||
return jsonify({"error": "Erro ao gerar gráfico"}), 500
|
|
||||||
|
|
||||||
# ROTAS DE PERFIL
|
|
||||||
@app.route('/api/user/profile', methods=['GET'])
|
|
||||||
def get_profile():
|
|
||||||
try:
|
|
||||||
user_id = request.args.get('user_id')
|
|
||||||
if not user_id:
|
|
||||||
return jsonify({"error": "User ID é obrigatório"}), 400
|
|
||||||
|
|
||||||
conn = get_db_connection()
|
|
||||||
cursor = conn.cursor()
|
|
||||||
|
|
||||||
cursor.execute('''
|
|
||||||
SELECT id, email, name, monthly_income_goal, monthly_expense_limit, currency
|
|
||||||
FROM users WHERE id = ?
|
|
||||||
''', (user_id,))
|
|
||||||
|
|
||||||
user = cursor.fetchone()
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
if not user:
|
|
||||||
return jsonify({"error": "Usuário não encontrado"}), 404
|
|
||||||
|
|
||||||
return jsonify({
|
|
||||||
"user": {
|
|
||||||
"id": user['id'],
|
|
||||||
"name": user['name'],
|
|
||||||
"email": user['email'],
|
|
||||||
"profile": {
|
|
||||||
"monthly_income_goal": user['monthly_income_goal'],
|
|
||||||
"monthly_expense_limit": user['monthly_expense_limit'],
|
|
||||||
"currency": user['currency']
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}), 200
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Erro ao buscar perfil: {e}")
|
|
||||||
return jsonify({"error": "Erro ao buscar perfil"}), 500
|
|
||||||
|
|
||||||
@app.route('/api/user/profile', methods=['PUT'])
|
|
||||||
def update_profile():
|
|
||||||
try:
|
|
||||||
data = request.get_json()
|
|
||||||
user_id = data.get('user_id')
|
|
||||||
name = data.get('name', '').strip()
|
|
||||||
email = data.get('email', '').strip().lower()
|
|
||||||
profile = data.get('profile', {})
|
|
||||||
|
|
||||||
if not user_id:
|
|
||||||
return jsonify({"error": "User ID é obrigatório"}), 400
|
|
||||||
|
|
||||||
if not name:
|
|
||||||
return jsonify({"error": "Nome é obrigatório"}), 400
|
|
||||||
|
|
||||||
conn = get_db_connection()
|
|
||||||
cursor = conn.cursor()
|
|
||||||
|
|
||||||
# Verificar se email já existe (para outro usuário)
|
|
||||||
cursor.execute('SELECT id FROM users WHERE email = ? AND id != ?', (email, user_id))
|
|
||||||
if cursor.fetchone():
|
|
||||||
conn.close()
|
|
||||||
return jsonify({"error": "Email já está em uso"}), 400
|
|
||||||
|
|
||||||
# Atualizar perfil
|
|
||||||
cursor.execute('''
|
|
||||||
UPDATE users
|
|
||||||
SET name = ?, email = ?, monthly_income_goal = ?, monthly_expense_limit = ?
|
|
||||||
WHERE id = ?
|
|
||||||
''', (name, email, profile.get('monthly_income_goal', 5000), profile.get('monthly_expense_limit', 2500), user_id))
|
|
||||||
|
|
||||||
if cursor.rowcount == 0:
|
|
||||||
conn.close()
|
|
||||||
return jsonify({"error": "Nenhuma alteração realizada"}), 400
|
|
||||||
|
|
||||||
conn.commit()
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
return jsonify({
|
|
||||||
"message": "Perfil atualizado com sucesso",
|
|
||||||
"user": {
|
|
||||||
"id": user_id,
|
|
||||||
"name": name,
|
|
||||||
"email": email,
|
|
||||||
"profile": profile
|
|
||||||
}
|
|
||||||
}), 200
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Erro ao atualizar perfil: {e}")
|
|
||||||
return jsonify({"error": "Erro ao atualizar perfil"}), 500
|
|
||||||
|
|
||||||
@app.route('/api/user/change-password', methods=['PUT'])
|
|
||||||
def change_password():
|
|
||||||
try:
|
|
||||||
data = request.get_json()
|
|
||||||
user_id = data.get('user_id')
|
|
||||||
current_password = data.get('current_password')
|
|
||||||
new_password = data.get('new_password')
|
|
||||||
|
|
||||||
if not user_id:
|
|
||||||
return jsonify({"error": "User ID é obrigatório"}), 400
|
|
||||||
|
|
||||||
conn = get_db_connection()
|
|
||||||
cursor = conn.cursor()
|
|
||||||
|
|
||||||
cursor.execute('SELECT password FROM users WHERE id = ?', (user_id,))
|
|
||||||
user = cursor.fetchone()
|
|
||||||
|
|
||||||
if not user:
|
|
||||||
conn.close()
|
|
||||||
return jsonify({"error": "Usuário não encontrado"}), 404
|
|
||||||
|
|
||||||
if not check_password(current_password, user['password']):
|
|
||||||
conn.close()
|
|
||||||
return jsonify({"error": "Senha atual incorreta"}), 400
|
|
||||||
|
|
||||||
if len(new_password) < 6:
|
|
||||||
conn.close()
|
|
||||||
return jsonify({"error": "A nova senha deve ter pelo menos 6 caracteres"}), 400
|
|
||||||
|
|
||||||
cursor.execute('UPDATE users SET password = ? WHERE id = ?', (hash_password(new_password), user_id))
|
|
||||||
conn.commit()
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
return jsonify({"message": "Senha alterada com sucesso"}), 200
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Erro ao alterar senha: {e}")
|
|
||||||
return jsonify({"error": "Erro ao alterar senha"}), 500
|
|
||||||
|
|
||||||
# Rota health check
|
|
||||||
@app.route('/api/health', methods=['GET'])
|
|
||||||
def health_check():
|
|
||||||
try:
|
|
||||||
# Testar conexão com SQLite
|
|
||||||
conn = get_db_connection()
|
|
||||||
cursor = conn.cursor()
|
|
||||||
cursor.execute('SELECT 1')
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
return jsonify({
|
|
||||||
"status": "OK",
|
|
||||||
"message": "Backend e SQLite funcionando",
|
|
||||||
"timestamp": datetime.now().isoformat()
|
|
||||||
}), 200
|
|
||||||
except Exception as e:
|
|
||||||
return jsonify({
|
|
||||||
"status": "ERROR",
|
|
||||||
"message": f"Erro no SQLite: {e}"
|
|
||||||
}), 500
|
|
||||||
|
|
||||||
@app.route('/')
|
|
||||||
def index():
|
|
||||||
return jsonify({
|
|
||||||
"message": "CtrlCash API está funcionando!",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"timestamp": datetime.now().isoformat()
|
|
||||||
})
|
|
||||||
|
|
||||||
# Inicializar o banco de dados quando o app iniciar
|
|
||||||
init_db()
|
|
||||||
print("✅ Banco de dados SQLite inicializado!")
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
print("🚀 Iniciando CtrlCash API com SQLite...")
|
|
||||||
print(f"💾 Database: {DB_PATH}")
|
|
||||||
app.run(debug=False, host='0.0.0.0', port=5000)
|
|
||||||
|
|
||||||
12
app/main.py
Normal file
12
app/main.py
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
from flask import Flask
|
||||||
|
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/")
|
||||||
|
def hello_world():
|
||||||
|
return "<p>Hello, World!</p>"
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app.run()
|
||||||
16
index.html
16
index.html
@@ -1,16 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<link rel="icon" href="./src/assets/icons/facivon.ico">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-sRIl4kxILFvY47J16cr9ZwB07vP4J8+LH7qKQnuqkuIAvNWLzeN8tE5YBujZqJLB" crossorigin="anonymous">
|
|
||||||
<title>CtrlCash</title>
|
|
||||||
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="app"></div>
|
|
||||||
<script type="module" src="/src/main.js"></script>
|
|
||||||
</body>
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/js/bootstrap.bundle.min.js" integrity="sha384-FKyoEForCGlyvwx9Hj09JcYn3nv7wiPVlz7YYwJrWVcXK/BmnVDxM+D2scQbITxI" crossorigin="anonymous"></script>
|
|
||||||
</html>
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"paths": {
|
|
||||||
"@/*": ["./src/*"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"exclude": ["node_modules", "dist"]
|
|
||||||
}
|
|
||||||
2561
package-lock.json
generated
2561
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
23
package.json
23
package.json
@@ -1,23 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "vue",
|
|
||||||
"version": "0.0.0",
|
|
||||||
"private": true,
|
|
||||||
"type": "module",
|
|
||||||
"engines": {
|
|
||||||
"node": "^20.19.0 || >=22.12.0"
|
|
||||||
},
|
|
||||||
"scripts": {
|
|
||||||
"dev": "vite",
|
|
||||||
"build": "vite build",
|
|
||||||
"preview": "vite preview"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"vue": "^3.5.22",
|
|
||||||
"vue-router": "^4.6.3"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@vitejs/plugin-vue": "^6.0.1",
|
|
||||||
"vite": "^7.1.11",
|
|
||||||
"vite-plugin-vue-devtools": "^8.0.3"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
2
requirements.txt
Normal file
2
requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
Flask==3.1.2
|
||||||
|
Werkzeug==3.1.3
|
||||||
10
src/App.vue
10
src/App.vue
@@ -1,10 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<RouterView />
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
</style>
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 14 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 12 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 192 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 30 KiB |
@@ -1,88 +0,0 @@
|
|||||||
<template>
|
|
||||||
<footer class="bg-dark-footer text-white pt-5 pb-3">
|
|
||||||
<div class="container">
|
|
||||||
<div class="row g-4">
|
|
||||||
|
|
||||||
<div class="col-md-4 col-12">
|
|
||||||
<img src="@/assets/CtrlCash-white.png" alt="CtrlCash Logo" width="120" height="35" class="mb-3">
|
|
||||||
<p class="text-secondary-footer mt-2">
|
|
||||||
Seu dinheiro. Seu controle total.
|
|
||||||
</p>
|
|
||||||
<p class="text-secondary-footer small">
|
|
||||||
Um projeto acadêmico de finanças inovadoras.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-md-2 col-6">
|
|
||||||
<h5 class="fw-bold text-white-footer mb-3">Navegue</h5>
|
|
||||||
<ul class="list-unstyled">
|
|
||||||
<li class="mb-2"><router-link to="/" class="text-secondary-footer text-decoration-none">Início</router-link></li>
|
|
||||||
<li class="mb-2"><router-link to="/about" class="text-secondary-footer text-decoration-none">Sobre Nós</router-link></li>
|
|
||||||
<li class="mb-2"><router-link to="/help" class="text-secondary-footer text-decoration-none">Ajuda</router-link></li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-md-3 col-6">
|
|
||||||
<h5 class="fw-bold text-white-footer mb-3">Projeto</h5>
|
|
||||||
<ul class="list-unstyled">
|
|
||||||
<li class="mb-2"><a href="https://github.com/Caio1w/CtrlCash" target="_blank" class="text-secondary-footer text-decoration-none">Repósitorio GitHub</a></li>
|
|
||||||
<li class="mb-2"><a href="/termos" class="text-secondary-footer text-decoration-none">Termos de Uso</a></li>
|
|
||||||
<li class="mb-2"><a href="/privacidade" class="text-secondary-footer text-decoration-none">Política de Privacidade</a></li>
|
|
||||||
<li class="mb-2"><a href="/equipe" class="text-secondary-footer text-decoration-none">Equipe de Desenvolvimento</a></li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-md-3 col-12">
|
|
||||||
<h5 class="fw-bold text-white-footer mb-3">Contato</h5>
|
|
||||||
<p class="text-secondary-footer mb-1">E-mail: contato@ctrlcash.com</p>
|
|
||||||
<p class="text-secondary-footer">Telefone: (XX) XXXX-XXXX</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<hr class="my-4 border-secondary-footer">
|
|
||||||
<div class="text-center">
|
|
||||||
<p class="text-secondary-footer small mb-0">
|
|
||||||
© 2025 CtrlCash - Todos os direitos reservados. Protótipo desenvolvido para fins acadêmicos.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
|
|
||||||
.bg-dark-footer {
|
|
||||||
background-color: #1A3B5E;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.text-white-footer {
|
|
||||||
color: #FFFFFF !important;
|
|
||||||
}
|
|
||||||
.text-secondary-footer {
|
|
||||||
color: #C0CCDA !important;
|
|
||||||
transition: color 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.text-success-footer {
|
|
||||||
color: #2ECC71 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.text-secondary-footer:hover {
|
|
||||||
color: #FFFFFF !important;
|
|
||||||
}
|
|
||||||
.text-success-footer:hover {
|
|
||||||
color: #34D399 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.border-secondary-footer {
|
|
||||||
border-color: rgba(255, 255, 255, 0.1) !important;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,185 +0,0 @@
|
|||||||
<template>
|
|
||||||
<nav class="navbar navbar-expand-lg navbar-dark bg-primary-dark shadow-lg sticky-top">
|
|
||||||
<div class="container-fluid max-w-7xl mx-auto px-4">
|
|
||||||
|
|
||||||
<!-- Logo e Branding -->
|
|
||||||
<router-link to="/dashboard" class="navbar-brand d-flex align-items-center me-4">
|
|
||||||
<img src="@/assets/CtrlCash-white.png" alt="CtrlCash Logo" width="120" class="d-inline-block logo-align">
|
|
||||||
</router-link>
|
|
||||||
|
|
||||||
<!-- Botão para Mobile (Hamburguer) -->
|
|
||||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
|
|
||||||
<span class="navbar-toggler-icon"></span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div class="collapse navbar-collapse" id="navbarNav">
|
|
||||||
|
|
||||||
<!-- Navegação Principal -->
|
|
||||||
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
|
|
||||||
<li class="nav-item">
|
|
||||||
<router-link to="/dashboard" :class="['nav-link', route.path === '/dashboard' ? 'active fw-bold text-white' : 'text-white-50', 'hover-success-feature']">Dashboard</router-link>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item">
|
|
||||||
<router-link to="/transacoes" :class="['nav-link', route.path === '/transacoes' ? 'active fw-bold text-white' : 'text-white-50', 'hover-success-feature']">Transações</router-link>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item">
|
|
||||||
<router-link to="/configuracoes" :class="['nav-link', route.path === '/configuracoes' ? 'active fw-bold text-white' : 'text-white-50', 'hover-success-feature']">Configurações</router-link>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<!-- Ícones de Ação e Usuário -->
|
|
||||||
<div class="d-flex align-items-center ms-auto">
|
|
||||||
|
|
||||||
<!-- Botão de Notificações -->
|
|
||||||
<button class="btn btn-link text-white me-3 p-0" title="Notificações">
|
|
||||||
<i class="bi bi-bell-fill fs-5 position-relative">
|
|
||||||
<span v-if="unreadNotifications > 0" class="position-absolute top-0 start-100 translate-middle p-1 bg-danger border border-light rounded-circle">
|
|
||||||
<small>{{ unreadNotifications > 9 ? '9+' : unreadNotifications }}</small>
|
|
||||||
</span>
|
|
||||||
</i>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- Avatar e Nome do Usuário -->
|
|
||||||
<div class="d-flex align-items-center me-3">
|
|
||||||
<img
|
|
||||||
class="rounded-circle border border-white"
|
|
||||||
:src="userAvatar"
|
|
||||||
:alt="`Avatar de ${userName}`"
|
|
||||||
style="width: 36px; height: 36px; object-fit: cover;"
|
|
||||||
>
|
|
||||||
<span class="ms-2 d-none d-md-inline text-sm text-white">{{ userName }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Botão de Logout - Desktop -->
|
|
||||||
<button @click="logout" class="btn btn-outline-light btn-sm d-none d-md-inline-flex align-items-center" title="Sair">
|
|
||||||
<i class="bi bi-box-arrow-right me-1"></i>
|
|
||||||
Sair
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- Botão de Logout - Mobile (dentro do menu) -->
|
|
||||||
<div class="d-md-none">
|
|
||||||
<button @click="logout" class="btn btn-outline-light btn-sm w-100 mt-2 d-flex align-items-center justify-content-center">
|
|
||||||
<i class="bi bi-box-arrow-right me-2"></i>
|
|
||||||
Sair
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { useRouter, useRoute } from 'vue-router';
|
|
||||||
import { ref, onMounted, watch } from 'vue';
|
|
||||||
|
|
||||||
const router = useRouter();
|
|
||||||
const route = useRoute();
|
|
||||||
|
|
||||||
// Estados reativos
|
|
||||||
const userName = ref('Usuário');
|
|
||||||
const userAvatar = ref('https://placehold.co/36x36/1A3B5E/FFFFFF?text=U');
|
|
||||||
const unreadNotifications = ref(0);
|
|
||||||
|
|
||||||
// Função para carregar dados do usuário
|
|
||||||
const loadUserData = () => {
|
|
||||||
try {
|
|
||||||
const userData = localStorage.getItem('user');
|
|
||||||
if (userData) {
|
|
||||||
const user = JSON.parse(userData);
|
|
||||||
userName.value = user.name || 'Usuário';
|
|
||||||
|
|
||||||
// Gerar avatar dinâmico baseado nas iniciais do nome
|
|
||||||
if (user.name) {
|
|
||||||
const initials = user.name
|
|
||||||
.split(' ')
|
|
||||||
.map(word => word[0])
|
|
||||||
.join('')
|
|
||||||
.toUpperCase()
|
|
||||||
.substring(0, 2);
|
|
||||||
|
|
||||||
userAvatar.value = `https://placehold.co/36x36/1A3B5E/FFFFFF?text=${initials}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Erro ao carregar dados do usuário:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const logout = () => {
|
|
||||||
console.log("Usuário desconectado. Redirecionando para o login.");
|
|
||||||
|
|
||||||
// Limpar dados de autenticação
|
|
||||||
localStorage.removeItem('user');
|
|
||||||
localStorage.removeItem('isAuthenticated');
|
|
||||||
localStorage.removeItem('token');
|
|
||||||
|
|
||||||
// Redirecionar para login
|
|
||||||
router.push('/login');
|
|
||||||
};
|
|
||||||
|
|
||||||
// Watcher para atualizar dados do usuário quando a rota mudar
|
|
||||||
watch(route, () => {
|
|
||||||
// Recarregar dados do usuário quando mudar de página
|
|
||||||
loadUserData();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Carregar dados quando o componente for montado
|
|
||||||
onMounted(() => {
|
|
||||||
loadUserData();
|
|
||||||
|
|
||||||
// Verificar se o usuário está autenticado
|
|
||||||
const isAuthenticated = localStorage.getItem('isAuthenticated');
|
|
||||||
const user = localStorage.getItem('user');
|
|
||||||
|
|
||||||
if (!isAuthenticated || !user) {
|
|
||||||
router.push('/login');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
/* Definição das cores customizadas com Bootstrap */
|
|
||||||
.bg-primary-dark { background-color: #1A3B5E !important; }
|
|
||||||
.text-primary-dark { color: #1A3B5E !important; }
|
|
||||||
.text-success-feature { color: #2ECC71 !important; }
|
|
||||||
.hover-success-feature:hover { color: #2ECC71 !important; }
|
|
||||||
|
|
||||||
/* FORÇA O ALINHAMENTO VERTICAL DA IMAGEM E DO TEXTO */
|
|
||||||
.navbar-brand {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
.logo-align {
|
|
||||||
vertical-align: middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Estilos para o botão de logout no mobile */
|
|
||||||
@media (max-width: 767.98px) {
|
|
||||||
.navbar-collapse {
|
|
||||||
padding-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-outline-light {
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
border-color: rgba(255, 255, 255, 0.5);
|
|
||||||
color: rgba(255, 255, 255, 0.8);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-outline-light:hover {
|
|
||||||
background-color: rgba(255, 255, 255, 0.1);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Estilos para a badge de notificações */
|
|
||||||
.position-relative .bg-danger {
|
|
||||||
font-size: 0.6rem;
|
|
||||||
min-width: 18px;
|
|
||||||
height: 18px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
<script>
|
|
||||||
// A tag <script> pode ficar vazia se não houver lógica (como imports de componentes ou métodos),
|
|
||||||
// mas é bom mantê-la para futuras expansões.
|
|
||||||
</script>
|
|
||||||
<template>
|
|
||||||
<header>
|
|
||||||
<nav class="navbar navbar-expand-lg px-3" :class="['bg-especial', 'navbar-dark']">
|
|
||||||
<div class="container-fluid">
|
|
||||||
|
|
||||||
<router-link class="navbar-brand p-0" to="/">
|
|
||||||
<img src="@/assets/CtrlCash-white.png" alt="CtrlCash Logo" width="140" height="40">
|
|
||||||
</router-link>
|
|
||||||
|
|
||||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
|
|
||||||
<span class="navbar-toggler-icon"></span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div class="collapse navbar-collapse" id="navbarNav">
|
|
||||||
|
|
||||||
<div class="navbar-nav ms-auto align-items-center">
|
|
||||||
|
|
||||||
<router-link to="/about" class="nav-link">Sobre nós</router-link>
|
|
||||||
<router-link to="/help" class="nav-link me-3">Ajuda</router-link>
|
|
||||||
|
|
||||||
<router-link to="/cadastro" class="btn btn-success me-2">Abrir Conta</router-link>
|
|
||||||
<router-link to="/login" class="btn btn-light text-primary">Login</router-link>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
</header>
|
|
||||||
</template>
|
|
||||||
<style scoped>
|
|
||||||
.bg-especial {
|
|
||||||
background-color: #1A3B5E;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-success {
|
|
||||||
background-color: #2ECC71;
|
|
||||||
border-color: #2ECC71;
|
|
||||||
}
|
|
||||||
.btn-success:hover {
|
|
||||||
background-color: #26a95f;
|
|
||||||
border-color: #26a95f;
|
|
||||||
}
|
|
||||||
|
|
||||||
.text-primary {
|
|
||||||
color: #1A3B5E !important;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
import { createApp } from 'vue'
|
|
||||||
import App from './App.vue'
|
|
||||||
import router from './router'
|
|
||||||
|
|
||||||
const app = createApp(App)
|
|
||||||
|
|
||||||
app.use(router)
|
|
||||||
|
|
||||||
app.mount('#app')
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
import { createRouter, createWebHistory } from 'vue-router'
|
|
||||||
import HomeView from '../views/HomeView.vue'
|
|
||||||
import UserDashboardView from '../views/UserDashboardView.vue'
|
|
||||||
import LoginView from '../views/LoginView.vue'
|
|
||||||
import CadastroView from '@/views/CadastroView.vue'
|
|
||||||
import TransacoesView from '@/views/TransacoesView.vue'
|
|
||||||
import ConfiguracoesView from '@/views/ConfiguracoesView.vue'
|
|
||||||
import SobreNosView from '@/views/SobreNosView.vue'
|
|
||||||
import AjudaView from '@/views/AjudaView.vue'
|
|
||||||
const router = createRouter({
|
|
||||||
history: createWebHistory(import.meta.env.BASE_URL),
|
|
||||||
routes: [
|
|
||||||
{
|
|
||||||
path: '/',
|
|
||||||
name: 'home',
|
|
||||||
component: HomeView
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/dashboard',
|
|
||||||
name: 'dashboard',
|
|
||||||
component: UserDashboardView
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/login',
|
|
||||||
name: 'login',
|
|
||||||
component: LoginView
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/cadastro',
|
|
||||||
name: 'cadastro',
|
|
||||||
component: CadastroView
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/transacoes',
|
|
||||||
name: 'transacoes',
|
|
||||||
component: TransacoesView
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/configuracoes',
|
|
||||||
name: 'configuracoes',
|
|
||||||
component: ConfiguracoesView
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/about',
|
|
||||||
name: 'about',
|
|
||||||
component: SobreNosView
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/help',
|
|
||||||
name: 'help',
|
|
||||||
component: AjudaView
|
|
||||||
},
|
|
||||||
|
|
||||||
|
|
||||||
],
|
|
||||||
})
|
|
||||||
|
|
||||||
export default router
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="ajuda-page bg-light min-vh-100">
|
|
||||||
|
|
||||||
<!-- Header Público (Links para Login/Cadastro) -->
|
|
||||||
<HeaderPublic />
|
|
||||||
|
|
||||||
<div class="container-fluid py-5 max-w-7xl mx-auto px-4">
|
|
||||||
|
|
||||||
<h1 class="display-5 fw-bold text-primary-dark mb-4 text-center">Central de Ajuda e FAQ</h1>
|
|
||||||
<p class="text-center text-muted mb-5">Encontre respostas rápidas para suas dúvidas mais comuns ou envie seu feedback.</p>
|
|
||||||
|
|
||||||
<div class="row justify-content-center">
|
|
||||||
<div class="col-lg-10">
|
|
||||||
|
|
||||||
<!-- ACORDEÃO DE PERGUNTAS FREQUENTES -->
|
|
||||||
<div class="accordion" id="faqAccordion">
|
|
||||||
|
|
||||||
<!-- Pergunta 1 -->
|
|
||||||
<div class="accordion-item shadow-sm mb-3 rounded-3">
|
|
||||||
<h2 class="accordion-header" id="headingOne">
|
|
||||||
<button class="accordion-button fw-bold text-primary-dark" type="button" data-bs-toggle="collapse" data-bs-target="#collapseOne" aria-expanded="true" aria-controls="collapseOne">
|
|
||||||
Como faço para registrar uma nova transação?
|
|
||||||
</button>
|
|
||||||
</h2>
|
|
||||||
<div id="collapseOne" class="accordion-collapse collapse show" aria-labelledby="headingOne" data-bs-parent="#faqAccordion">
|
|
||||||
<div class="accordion-body text-muted small">
|
|
||||||
No seu Dashboard, clique em "Transações" na barra de navegação. Na View de Transações, use o botão "Nova Transação" (verde) para adicionar Receitas ou Despesas. Certifique-se de categorizar corretamente para as análises futuras.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
<!-- Pergunta 2 -->
|
|
||||||
<div class="accordion-item shadow-sm mb-3 rounded-3">
|
|
||||||
<h2 class="accordion-header" id="headingThree">
|
|
||||||
<button class="accordion-button collapsed fw-bold text-primary-dark" type="button" data-bs-toggle="collapse" data-bs-target="#collapseThree" aria-expanded="false" aria-controls="collapseThree">
|
|
||||||
Posso adicionar minhas próprias categorias?
|
|
||||||
</button>
|
|
||||||
</h2>
|
|
||||||
<div id="collapseThree" class="accordion-collapse collapse" aria-labelledby="headingThree" data-bs-parent="#faqAccordion">
|
|
||||||
<div class="accordion-body text-muted small">
|
|
||||||
Sim! Vá para a View "Configurações" e selecione a aba "Gestão de Categorias". Lá você pode criar, editar ou remover categorias personalizadas para se adequar ao seu estilo de vida financeiro.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<FooterPublic />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import FooterPublic from '@/components/FooterPublic.vue'
|
|
||||||
import HeaderPublic from '@/components/HeaderPublic.vue';
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
/* Estilos Customizados do CtrlCash */
|
|
||||||
.bg-primary-dark { background-color: #1A3B5E !important; }
|
|
||||||
.text-primary-dark { color: #1A3B5E !important; }
|
|
||||||
.text-success-feature { color: #2ECC71 !important; }
|
|
||||||
.btn-success-feature { background-color: #2ECC71 !important; border-color: #2ECC71 !important; color: white; }
|
|
||||||
|
|
||||||
.max-w-7xl { max-width: 80rem; }
|
|
||||||
.mx-auto { margin-left: auto !important; margin-right: auto !important; }
|
|
||||||
|
|
||||||
.accordion-button:not(.collapsed) {
|
|
||||||
color: white !important;
|
|
||||||
background-color: #1A3B5E !important;
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
.accordion-button:focus {
|
|
||||||
box-shadow: none;
|
|
||||||
border-color: #1A3B5E;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,160 +0,0 @@
|
|||||||
<template>
|
|
||||||
<HeaderPublic />
|
|
||||||
<div class="cadastro-container d-flex align-items-center justify-content-center min-vh-100 p-3">
|
|
||||||
<div class="cadastro-card p-5 shadow-lg rounded-4 bg-white">
|
|
||||||
|
|
||||||
<div class="text-center mb-4">
|
|
||||||
<img src="@/assets/CtrlCash-blue.png" alt="CtrlCash Logo" width="150" class="mb-3">
|
|
||||||
<h2 class="fw-bold text-primary-dark">Abra Sua Conta</h2>
|
|
||||||
<p class="text-secondary-dark">Preencha os campos para iniciar seu controle financeiro.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form @submit.prevent="handleCadastro">
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="nome" class="form-label fw-medium text-primary-dark">Nome Completo</label>
|
|
||||||
<div class="input-group">
|
|
||||||
<span class="input-group-text"><i class="bi bi-person-fill"></i></span>
|
|
||||||
<input type="text" class="form-control" id="nome" v-model="cadastroForm.name" required placeholder="Seu nome">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="email" class="form-label fw-medium text-primary-dark">E-mail</label>
|
|
||||||
<div class="input-group">
|
|
||||||
<span class="input-group-text"><i class="bi bi-envelope-fill"></i></span>
|
|
||||||
<input type="email" class="form-control" id="email" v-model="cadastroForm.email" required placeholder="seu.email@exemplo.com">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="password" class="form-label fw-medium text-primary-dark">Crie Sua Senha</label>
|
|
||||||
<div class="input-group">
|
|
||||||
<span class="input-group-text"><i class="bi bi-lock-fill"></i></span>
|
|
||||||
<input type="password" class="form-control" id="password" v-model="cadastroForm.password" required placeholder="Mínimo 6 caracteres">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-4">
|
|
||||||
<label for="confirmPassword" class="form-label fw-medium text-primary-dark">Confirmar Senha</label>
|
|
||||||
<div class="input-group">
|
|
||||||
<span class="input-group-text"><i class="bi bi-lock-fill"></i></span>
|
|
||||||
<input type="password" class="form-control" id="confirmPassword" v-model="confirmPassword" required placeholder="Repita a senha">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Mensagens -->
|
|
||||||
<div v-if="error" class="alert alert-danger">
|
|
||||||
{{ error }}
|
|
||||||
</div>
|
|
||||||
<div v-if="success" class="alert alert-success">
|
|
||||||
{{ success }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button type="submit" class="btn btn-primary-feature w-100 fw-bold py-2 shadow-sm" :disabled="loading">
|
|
||||||
<span v-if="loading" class="spinner-border spinner-border-sm me-2"></span>
|
|
||||||
{{ loading ? 'Cadastrando...' : 'Criar Minha Conta CtrlCash' }}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { ref } from 'vue';
|
|
||||||
import { useRouter } from 'vue-router';
|
|
||||||
import HeaderPublic from '@/components/HeaderPublic.vue';
|
|
||||||
|
|
||||||
const router = useRouter();
|
|
||||||
const loading = ref(false);
|
|
||||||
const error = ref('');
|
|
||||||
const success = ref('');
|
|
||||||
|
|
||||||
const cadastroForm = ref({
|
|
||||||
name: '',
|
|
||||||
email: '',
|
|
||||||
password: ''
|
|
||||||
});
|
|
||||||
const confirmPassword = ref('');
|
|
||||||
|
|
||||||
const handleCadastro = async () => {
|
|
||||||
loading.value = true;
|
|
||||||
error.value = '';
|
|
||||||
success.value = '';
|
|
||||||
|
|
||||||
// Validações
|
|
||||||
if (cadastroForm.value.password !== confirmPassword.value) {
|
|
||||||
error.value = "As senhas não coincidem!";
|
|
||||||
loading.value = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cadastroForm.value.password.length < 6) {
|
|
||||||
error.value = "A senha deve ter pelo menos 6 caracteres";
|
|
||||||
loading.value = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/auth/register', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify(cadastroForm.value)
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
success.value = 'Cadastro realizado com sucesso! Redirecionando...';
|
|
||||||
|
|
||||||
// Auto-login após cadastro
|
|
||||||
setTimeout(() => {
|
|
||||||
localStorage.setItem('user', JSON.stringify(data.user));
|
|
||||||
localStorage.setItem('isAuthenticated', 'true');
|
|
||||||
router.push('/dashboard');
|
|
||||||
}, 2000);
|
|
||||||
} else {
|
|
||||||
error.value = data.error || 'Erro ao cadastrar';
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Erro:', err);
|
|
||||||
error.value = 'Erro de conexão com o servidor';
|
|
||||||
} finally {
|
|
||||||
loading.value = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.text-primary-dark { color: #1A3B5E !important; }
|
|
||||||
.text-secondary-dark { color: #6c757d !important; }
|
|
||||||
|
|
||||||
.cadastro-container { background-color: #F8F9FA; }
|
|
||||||
.cadastro-card { max-width: 450px; width: 100%; }
|
|
||||||
|
|
||||||
.btn-primary-feature {
|
|
||||||
background-color: #1A3B5E;
|
|
||||||
border-color: #1A3B5E;
|
|
||||||
color: white;
|
|
||||||
transition: background-color 0.2s;
|
|
||||||
}
|
|
||||||
.btn-primary-feature:hover:not(:disabled) {
|
|
||||||
background-color: #29517b;
|
|
||||||
border-color: #29517b;
|
|
||||||
}
|
|
||||||
.btn-primary-feature:disabled {
|
|
||||||
opacity: 0.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-control:focus {
|
|
||||||
border-color: #1A3B5E;
|
|
||||||
box-shadow: 0 0 0 0.25rem rgba(26, 59, 94, 0.25);
|
|
||||||
}
|
|
||||||
.input-group-text {
|
|
||||||
background-color: #e9ecef;
|
|
||||||
border-right: none;
|
|
||||||
color: #1A3B5E;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,588 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="config-page bg-light min-vh-100">
|
|
||||||
|
|
||||||
<!-- Header da Aplicação -->
|
|
||||||
<HeaderApp />
|
|
||||||
|
|
||||||
<div class="container-fluid py-4 max-w-7xl mx-auto px-4">
|
|
||||||
|
|
||||||
<h1 class="h3 fw-bold text-primary-dark mb-4">Configurações e Gestão da Conta</h1>
|
|
||||||
|
|
||||||
<div class="row g-4">
|
|
||||||
|
|
||||||
<!-- Coluna de Navegação Lateral -->
|
|
||||||
<div class="col-lg-3">
|
|
||||||
<div class="list-group shadow-sm border-0 rounded-3">
|
|
||||||
<a href="#"
|
|
||||||
@click.prevent="setActiveTab('perfil')"
|
|
||||||
:class="['list-group-item list-group-item-action', {'active-feature': activeTab === 'perfil'}]">
|
|
||||||
<i class="bi bi-person-circle me-2"></i> Perfil e Dados Pessoais
|
|
||||||
</a>
|
|
||||||
<a href="#"
|
|
||||||
@click.prevent="setActiveTab('categorias')"
|
|
||||||
:class="['list-group-item list-group-item-action', {'active-feature': activeTab === 'categorias'}]">
|
|
||||||
<i class="bi bi-tags-fill me-2"></i> Gestão de Categorias
|
|
||||||
</a>
|
|
||||||
<a href="#"
|
|
||||||
@click.prevent="setActiveTab('seguranca')"
|
|
||||||
:class="['list-group-item list-group-item-action', {'active-feature': activeTab === 'seguranca'}]">
|
|
||||||
<i class="bi bi-lock-fill me-2"></i> Segurança e Senha
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Área de Conteúdo (Abas) -->
|
|
||||||
<div class="col-lg-9">
|
|
||||||
<div class="card shadow-sm border-0 p-4 h-100">
|
|
||||||
|
|
||||||
<!-- Aba: Perfil e Dados Pessoais -->
|
|
||||||
<div v-if="activeTab === 'perfil'">
|
|
||||||
<h2 class="h4 fw-bold text-primary-dark mb-3">Meu Perfil</h2>
|
|
||||||
<p class="text-muted">Gerencie seu nome, e-mail e outras informações de contato.</p>
|
|
||||||
|
|
||||||
<form @submit.prevent="updateProfile">
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-6 mb-3">
|
|
||||||
<label class="form-label fw-medium">Nome Completo</label>
|
|
||||||
<input type="text" class="form-control" v-model="profileForm.name" required>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-6 mb-3">
|
|
||||||
<label class="form-label fw-medium">E-mail</label>
|
|
||||||
<input type="email" class="form-control" v-model="profileForm.email" required>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-6 mb-3">
|
|
||||||
<label class="form-label fw-medium">Meta de Receita Mensal (R$)</label>
|
|
||||||
<input type="number" class="form-control" v-model="profileForm.profile.monthly_income_goal" step="0.01" min="0">
|
|
||||||
</div>
|
|
||||||
<div class="col-md-6 mb-3">
|
|
||||||
<label class="form-label fw-medium">Limite de Despesas Mensal (R$)</label>
|
|
||||||
<input type="number" class="form-control" v-model="profileForm.profile.monthly_expense_limit" step="0.01" min="0">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Mensagens -->
|
|
||||||
<div v-if="profileMessage" class="alert" :class="profileMessage.type === 'success' ? 'alert-success' : 'alert-danger'">
|
|
||||||
{{ profileMessage.text }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button class="btn btn-primary-feature mt-2" :disabled="updatingProfile">
|
|
||||||
<span v-if="updatingProfile" class="spinner-border spinner-border-sm me-2"></span>
|
|
||||||
{{ updatingProfile ? 'Salvando...' : 'Salvar Alterações' }}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Aba: Gestão de Categorias -->
|
|
||||||
<div v-if="activeTab === 'categorias'">
|
|
||||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
|
||||||
<h2 class="h4 fw-bold text-primary-dark mb-0">Categorias Financeiras</h2>
|
|
||||||
<button class="btn btn-success-feature" @click="showAddCategoryModal = true">
|
|
||||||
<i class="bi bi-plus-circle me-2"></i> Nova Categoria
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<p class="text-muted">Crie, edite ou remova categorias de receitas e despesas.</p>
|
|
||||||
|
|
||||||
<!-- Loading State -->
|
|
||||||
<div v-if="loadingCategories" class="text-center py-4">
|
|
||||||
<div class="spinner-border text-primary-dark" role="status"></div>
|
|
||||||
<p class="text-muted mt-2">Carregando categorias...</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Lista de Categorias -->
|
|
||||||
<div v-else class="row">
|
|
||||||
<div class="col-md-6">
|
|
||||||
<h5 class="fw-bold text-success mb-3">📈 Receitas</h5>
|
|
||||||
<div class="list-group mb-4">
|
|
||||||
<div
|
|
||||||
v-for="category in incomeCategories"
|
|
||||||
:key="category.id"
|
|
||||||
class="list-group-item d-flex justify-content-between align-items-center"
|
|
||||||
>
|
|
||||||
<div class="d-flex align-items-center">
|
|
||||||
<span
|
|
||||||
class="badge me-2"
|
|
||||||
:style="{
|
|
||||||
backgroundColor: category.color,
|
|
||||||
width: '15px',
|
|
||||||
height: '15px',
|
|
||||||
display: 'inline-block'
|
|
||||||
}"
|
|
||||||
></span>
|
|
||||||
{{ category.name }}
|
|
||||||
</div>
|
|
||||||
<span class="badge bg-success">Receita</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-md-6">
|
|
||||||
<h5 class="fw-bold text-danger mb-3">📉 Despesas</h5>
|
|
||||||
<div class="list-group">
|
|
||||||
<div
|
|
||||||
v-for="category in expenseCategories"
|
|
||||||
:key="category.id"
|
|
||||||
class="list-group-item d-flex justify-content-between align-items-center"
|
|
||||||
>
|
|
||||||
<div class="d-flex align-items-center">
|
|
||||||
<span
|
|
||||||
class="badge me-2"
|
|
||||||
:style="{
|
|
||||||
backgroundColor: category.color,
|
|
||||||
width: '15px',
|
|
||||||
height: '15px',
|
|
||||||
display: 'inline-block'
|
|
||||||
}"
|
|
||||||
></span>
|
|
||||||
{{ category.name }}
|
|
||||||
</div>
|
|
||||||
<span class="badge bg-danger">Despesa</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Mensagem de Categorias -->
|
|
||||||
<div v-if="categoriesMessage" class="alert" :class="categoriesMessage.type === 'success' ? 'alert-success' : 'alert-danger'">
|
|
||||||
{{ categoriesMessage.text }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Aba: Segurança e Senha -->
|
|
||||||
<div v-if="activeTab === 'seguranca'">
|
|
||||||
<h2 class="h4 fw-bold text-primary-dark mb-3">Segurança da Conta</h2>
|
|
||||||
<p class="text-muted">Altere sua senha para manter sua conta segura.</p>
|
|
||||||
|
|
||||||
<form @submit.prevent="changePassword">
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label fw-medium">Senha Atual</label>
|
|
||||||
<input type="password" class="form-control" v-model="passwordForm.currentPassword" required>
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label fw-medium">Nova Senha</label>
|
|
||||||
<input type="password" class="form-control" v-model="passwordForm.newPassword" required minlength="6">
|
|
||||||
<small class="text-muted">Mínimo 6 caracteres</small>
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label fw-medium">Confirmar Nova Senha</label>
|
|
||||||
<input type="password" class="form-control" v-model="passwordForm.confirmPassword" required>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Mensagens -->
|
|
||||||
<div v-if="passwordMessage" class="alert" :class="passwordMessage.type === 'success' ? 'alert-success' : 'alert-danger'">
|
|
||||||
{{ passwordMessage.text }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button class="btn btn-warning text-white mt-2" :disabled="changingPassword">
|
|
||||||
<span v-if="changingPassword" class="spinner-border spinner-border-sm me-2"></span>
|
|
||||||
{{ changingPassword ? 'Alterando...' : 'Alterar Senha' }}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Modal Adicionar Categoria -->
|
|
||||||
<div class="modal fade" :class="{ 'show d-block': showAddCategoryModal }" tabindex="-1" v-if="showAddCategoryModal">
|
|
||||||
<div class="modal-dialog modal-dialog-centered">
|
|
||||||
<div class="modal-content">
|
|
||||||
<div class="modal-header bg-primary-dark text-white">
|
|
||||||
<h5 class="modal-title fw-bold">Nova Categoria</h5>
|
|
||||||
<button type="button" class="btn-close btn-close-white" @click="closeCategoryModal"></button>
|
|
||||||
</div>
|
|
||||||
<div class="modal fade" :class="{ 'show d-block': showAddCategoryModal }" tabindex="-1" v-if="showAddCategoryModal">
|
|
||||||
<div class="modal-dialog modal-dialog-centered">
|
|
||||||
<div class="modal-content">
|
|
||||||
<div class="modal-header bg-primary-dark text-white">
|
|
||||||
<h5 class="modal-title fw-bold">Nova Categoria</h5>
|
|
||||||
<button type="button" class="btn-close btn-close-white" @click="closeCategoryModal"></button>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body">
|
|
||||||
<form @submit.prevent="addCategory" id="categoryForm">
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label fw-medium">Nome da Categoria</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
class="form-control"
|
|
||||||
v-model="newCategory.name"
|
|
||||||
placeholder="Ex: Investimentos, Lazer, Educação..."
|
|
||||||
required
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label fw-medium">Tipo</label>
|
|
||||||
<select v-model="newCategory.type" class="form-select" required>
|
|
||||||
<option value="income">Receita</option>
|
|
||||||
<option value="expense">Despesa</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label fw-medium">Cor</label>
|
|
||||||
<div class="d-flex align-items-center">
|
|
||||||
<input
|
|
||||||
type="color"
|
|
||||||
class="form-control form-control-color"
|
|
||||||
v-model="newCategory.color"
|
|
||||||
required
|
|
||||||
>
|
|
||||||
<span class="ms-2 small text-muted">Escolha uma cor para identificar</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Mensagens -->
|
|
||||||
<div v-if="categoryMessage" class="alert" :class="categoryMessage.type === 'success' ? 'alert-success' : 'alert-danger'">
|
|
||||||
{{ categoryMessage.text }}
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer">
|
|
||||||
<button type="button" class="btn btn-secondary" @click="closeCategoryModal">Cancelar</button>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="btn btn-primary-dark-feature fw-bold"
|
|
||||||
form="categoryForm"
|
|
||||||
:disabled="addingCategory"
|
|
||||||
>
|
|
||||||
<span v-if="addingCategory" class="spinner-border spinner-border-sm me-2"></span>
|
|
||||||
{{ addingCategory ? 'Adicionando...' : 'Adicionar Categoria' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="modal-backdrop fade show" @click="closeCategoryModal"></div>
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer">
|
|
||||||
<button type="button" class="btn btn-secondary" @click="closeCategoryModal">Cancelar</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn btn-primary-dark-feature fw-bold"
|
|
||||||
@click="addCategory"
|
|
||||||
:disabled="addingCategory"
|
|
||||||
>
|
|
||||||
<span v-if="addingCategory" class="spinner-border spinner-border-sm me-2"></span>
|
|
||||||
{{ addingCategory ? 'Adicionando...' : 'Adicionar Categoria' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="modal-backdrop fade show" @click="closeCategoryModal"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { ref, onMounted, computed } from 'vue';
|
|
||||||
import { useRouter } from 'vue-router';
|
|
||||||
import HeaderApp from '@/components/HeaderApp.vue';
|
|
||||||
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
// Estados reativos
|
|
||||||
const activeTab = ref('perfil');
|
|
||||||
const loadingCategories = ref(false);
|
|
||||||
const updatingProfile = ref(false);
|
|
||||||
const changingPassword = ref(false);
|
|
||||||
const addingCategory = ref(false);
|
|
||||||
const showAddCategoryModal = ref(false);
|
|
||||||
|
|
||||||
// Dados do usuário
|
|
||||||
const userProfile = ref({
|
|
||||||
name: '',
|
|
||||||
email: '',
|
|
||||||
profile: {
|
|
||||||
monthly_income_goal: 5000,
|
|
||||||
monthly_expense_limit: 2500,
|
|
||||||
currency: 'BRL'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const categories = ref([]);
|
|
||||||
|
|
||||||
// Forms
|
|
||||||
const profileForm = ref({
|
|
||||||
name: '',
|
|
||||||
email: '',
|
|
||||||
profile: {
|
|
||||||
monthly_income_goal: 5000,
|
|
||||||
monthly_expense_limit: 2500,
|
|
||||||
currency: 'BRL'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const passwordForm = ref({
|
|
||||||
currentPassword: '',
|
|
||||||
newPassword: '',
|
|
||||||
confirmPassword: ''
|
|
||||||
});
|
|
||||||
|
|
||||||
const newCategory = ref({
|
|
||||||
name: '',
|
|
||||||
type: 'expense',
|
|
||||||
color: '#6c757d'
|
|
||||||
});
|
|
||||||
|
|
||||||
// Mensagens
|
|
||||||
const profileMessage = ref(null);
|
|
||||||
const passwordMessage = ref(null);
|
|
||||||
const categoriesMessage = ref(null);
|
|
||||||
const categoryMessage = ref(null);
|
|
||||||
|
|
||||||
// Computed
|
|
||||||
const incomeCategories = computed(() => {
|
|
||||||
return categories.value.filter(cat => cat.type === 'income');
|
|
||||||
});
|
|
||||||
|
|
||||||
const expenseCategories = computed(() => {
|
|
||||||
return categories.value.filter(cat => cat.type === 'expense');
|
|
||||||
});
|
|
||||||
|
|
||||||
// Métodos
|
|
||||||
const setActiveTab = (tabName) => {
|
|
||||||
activeTab.value = tabName;
|
|
||||||
};
|
|
||||||
|
|
||||||
const loadUserProfile = async () => {
|
|
||||||
try {
|
|
||||||
const user = JSON.parse(localStorage.getItem('user'));
|
|
||||||
if (!user) {
|
|
||||||
router.push('/login');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(`/api/user/profile?user_id=${user.id}`);
|
|
||||||
if (response.ok) {
|
|
||||||
const data = await response.json();
|
|
||||||
userProfile.value = data.user;
|
|
||||||
profileForm.value = { ...data.user };
|
|
||||||
|
|
||||||
// Atualizar localStorage
|
|
||||||
localStorage.setItem('user', JSON.stringify(data.user));
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Erro ao carregar perfil:', error);
|
|
||||||
showMessage(profileMessage, 'Erro ao carregar perfil', 'error');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const loadCategories = async () => {
|
|
||||||
loadingCategories.value = true;
|
|
||||||
try {
|
|
||||||
const user = JSON.parse(localStorage.getItem('user'));
|
|
||||||
const response = await fetch(`/api/categories?user_id=${user.id}`);
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
categories.value = data.categories;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Erro ao carregar categorias:', error);
|
|
||||||
showMessage(categoriesMessage, 'Erro ao carregar categorias', 'error');
|
|
||||||
} finally {
|
|
||||||
loadingCategories.value = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateProfile = async () => {
|
|
||||||
updatingProfile.value = true;
|
|
||||||
profileMessage.value = null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const user = JSON.parse(localStorage.getItem('user'));
|
|
||||||
const response = await fetch('/api/user/profile', {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
user_id: user.id,
|
|
||||||
name: profileForm.value.name,
|
|
||||||
email: profileForm.value.email,
|
|
||||||
profile: profileForm.value.profile
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
// Atualizar localStorage
|
|
||||||
const updatedUser = { ...user, name: data.user.name, email: data.user.email, profile: data.user.profile };
|
|
||||||
localStorage.setItem('user', JSON.stringify(updatedUser));
|
|
||||||
|
|
||||||
showMessage(profileMessage, 'Perfil atualizado com sucesso!', 'success');
|
|
||||||
} else {
|
|
||||||
showMessage(profileMessage, data.error || 'Erro ao atualizar perfil', 'error');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Erro:', error);
|
|
||||||
showMessage(profileMessage, 'Erro de conexão com o servidor', 'error');
|
|
||||||
} finally {
|
|
||||||
updatingProfile.value = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const changePassword = async () => {
|
|
||||||
changingPassword.value = true;
|
|
||||||
passwordMessage.value = null;
|
|
||||||
|
|
||||||
// Validações
|
|
||||||
if (passwordForm.value.newPassword !== passwordForm.value.confirmPassword) {
|
|
||||||
showMessage(passwordMessage, 'As senhas não coincidem', 'error');
|
|
||||||
changingPassword.value = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (passwordForm.value.newPassword.length < 6) {
|
|
||||||
showMessage(passwordMessage, 'A senha deve ter pelo menos 6 caracteres', 'error');
|
|
||||||
changingPassword.value = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const user = JSON.parse(localStorage.getItem('user'));
|
|
||||||
const response = await fetch('/api/user/change-password', {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
user_id: user.id,
|
|
||||||
current_password: passwordForm.value.currentPassword,
|
|
||||||
new_password: passwordForm.value.newPassword
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
showMessage(passwordMessage, 'Senha alterada com sucesso!', 'success');
|
|
||||||
passwordForm.value = {
|
|
||||||
currentPassword: '',
|
|
||||||
newPassword: '',
|
|
||||||
confirmPassword: ''
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
showMessage(passwordMessage, data.error || 'Erro ao alterar senha', 'error');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Erro:', error);
|
|
||||||
showMessage(passwordMessage, 'Erro de conexão com o servidor', 'error');
|
|
||||||
} finally {
|
|
||||||
changingPassword.value = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const addCategory = async () => {
|
|
||||||
addingCategory.value = true;
|
|
||||||
categoryMessage.value = null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const user = JSON.parse(localStorage.getItem('user'));
|
|
||||||
const response = await fetch('/api/categories', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
user_id: user.id,
|
|
||||||
name: newCategory.value.name,
|
|
||||||
type: newCategory.value.type,
|
|
||||||
color: newCategory.value.color
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
showMessage(categoryMessage, 'Categoria adicionada com sucesso!', 'success');
|
|
||||||
closeCategoryModal();
|
|
||||||
loadCategories();
|
|
||||||
} else {
|
|
||||||
showMessage(categoryMessage, data.error || 'Erro ao adicionar categoria', 'error');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Erro:', error);
|
|
||||||
showMessage(categoryMessage, 'Erro de conexão com o servidor', 'error');
|
|
||||||
} finally {
|
|
||||||
addingCategory.value = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const closeCategoryModal = () => {
|
|
||||||
showAddCategoryModal.value = false;
|
|
||||||
newCategory.value = {
|
|
||||||
name: '',
|
|
||||||
type: 'expense',
|
|
||||||
color: '#6c757d'
|
|
||||||
};
|
|
||||||
categoryMessage.value = null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const showMessage = (messageRef, text, type) => {
|
|
||||||
messageRef.value = { text, type };
|
|
||||||
setTimeout(() => {
|
|
||||||
messageRef.value = null;
|
|
||||||
}, 5000);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Lifecycle
|
|
||||||
onMounted(() => {
|
|
||||||
loadUserProfile();
|
|
||||||
loadCategories();
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
/* Estilos Customizados do CtrlCash */
|
|
||||||
.bg-primary-dark { background-color: #1A3B5E !important; }
|
|
||||||
.text-primary-dark { color: #1A3B5E !important; }
|
|
||||||
.btn-primary-feature {
|
|
||||||
background-color: #1A3B5E !important;
|
|
||||||
border-color: #1A3B5E !important;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
.btn-primary-feature:disabled {
|
|
||||||
opacity: 0.6;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
.btn-success-feature {
|
|
||||||
background-color: #2ECC71 !important;
|
|
||||||
border-color: #2ECC71 !important;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
.active-feature {
|
|
||||||
background-color: #1A3B5E !important;
|
|
||||||
color: white !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.max-w-7xl { max-width: 80rem; }
|
|
||||||
.mx-auto { margin-left: auto !important; margin-right: auto !important; }
|
|
||||||
|
|
||||||
/* Modal backdrop */
|
|
||||||
.modal-backdrop {
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Form controls */
|
|
||||||
.form-control:focus {
|
|
||||||
border-color: #1A3B5E;
|
|
||||||
box-shadow: 0 0 0 0.25rem rgba(26, 59, 94, 0.25);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* List group items */
|
|
||||||
.list-group-item {
|
|
||||||
border: 1px solid #dee2e6;
|
|
||||||
margin-bottom: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Color picker */
|
|
||||||
.form-control-color {
|
|
||||||
width: 50px;
|
|
||||||
height: 38px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,154 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import HeaderPublic from '../components/HeaderPublic.vue'
|
|
||||||
import FooterPublic from '../components/FooterPublic.vue'
|
|
||||||
</script>
|
|
||||||
<template>
|
|
||||||
<HeaderPublic />
|
|
||||||
|
|
||||||
|
|
||||||
<main class="main-no-gap">
|
|
||||||
|
|
||||||
|
|
||||||
<div class="bg-especial">
|
|
||||||
<section class="container p-3 row align-items-center m-auto">
|
|
||||||
|
|
||||||
<div class="col-md-6 col-12 py-5">
|
|
||||||
<h1 class="text-white display-10 text fw-bold text-break">Seu dinheiro. <br> Seu controle total.</h1>
|
|
||||||
<p class="text-secondary fs-5">Organize, invista e alcance seus objetivos financeiros com segurança e facilidade. </p>
|
|
||||||
|
|
||||||
<router-link to="/register" class="btn btn-success btn-lg mt-3 fw-bold me-3">Abra sua conta</router-link>
|
|
||||||
<router-link to="/features" class="btn btn-outline-light btn-lg mt-3">Conheça os recursos</router-link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
<div class="col-md-6 col-12 d-flex justify-content-center py-5">
|
|
||||||
<img src="@/assets/Hero.png" alt="Ilustração de controle financeiro do CtrlCash" class="img-fluid">
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
<section class="container py-5">
|
|
||||||
|
|
||||||
<div class="row text-center mb-5">
|
|
||||||
<div class="col-lg-8 mx-auto">
|
|
||||||
|
|
||||||
<h2 class="fw-bold display-6 text-primary-dark">Concentre-se no que importa. Nós cuidamos do resto.</h2>
|
|
||||||
<p class="lead text-secondary-feature mt-3">Descubra as funcionalidades que dão o controle total da sua vida financeira na palma da sua mão.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
<div class="row g-5">
|
|
||||||
|
|
||||||
|
|
||||||
<div class="col-lg-4 col-md-6 col-12">
|
|
||||||
<div class="d-flex align-items-start">
|
|
||||||
<i class="bi bi-pie-chart-fill display-5 me-5 text-success-feature flex-shrink-0"></i>
|
|
||||||
<div>
|
|
||||||
<h4 class="fw-bold mb-1 text-dark-feature">Orçamento Inteligente</h4>
|
|
||||||
<p class="text-secondary-feature">Crie orçamentos por categoria, receba alertas e visualize seus gastos em tempo real, evitando surpresas no fim do mês.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
<div class="col-lg-4 col-md-6 col-12">
|
|
||||||
<div class="d-flex align-items-start">
|
|
||||||
<i class="bi bi-rocket-takeoff-fill display-5 me-4 text-success-feature flex-shrink-0"></i>
|
|
||||||
<div>
|
|
||||||
<h4 class="fw-bold mb-1 text-dark-feature">Metas e Investimentos</h4>
|
|
||||||
<p class="text-secondary-feature">Defina seus objetivos (viagem, casa, carro) e o CtrlCash te ajuda a poupar, investir e acompanhar seu progresso.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
<div class="col-lg-4 col-md-6 col-12">
|
|
||||||
<div class="d-flex align-items-start">
|
|
||||||
<i class="bi bi-lightning-charge-fill display-5 me-4 text-success-feature flex-shrink-0"></i>
|
|
||||||
<div>
|
|
||||||
<h4 class="fw-bold mb-1 text-dark-feature">PIX e Pagamentos Rápidos</h4>
|
|
||||||
<p class="text-secondary-feature">Envie e receba dinheiro em segundos. Pague contas e boletos com agilidade, tudo em um só lugar.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
<div class="col-lg-4 col-md-6 col-12">
|
|
||||||
<div class="d-flex align-items-start">
|
|
||||||
<i class="bi bi-clock-history display-5 me-4 text-success-feature flex-shrink-0"></i>
|
|
||||||
<div>
|
|
||||||
<h4 class="fw-bold mb-1 text-dark-feature">Histórico Automático</h4>
|
|
||||||
<p class="text-secondary-feature">Chega de planilhas. O app registra todas as transações, categoriza e gera relatórios visuais inteligentes para você.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Recurso 5: Segurança e Alertas -->
|
|
||||||
<div class="col-lg-4 col-md-6 col-12">
|
|
||||||
<div class="d-flex align-items-start">
|
|
||||||
<i class="bi bi-lock-fill display-5 me-4 text-success-feature flex-shrink-0"></i>
|
|
||||||
<div>
|
|
||||||
<h4 class="fw-bold mb-1 text-dark-feature">Segurança e Alertas</h4>
|
|
||||||
<p class="text-secondary-feature">Monitoramento 24h e notificações em tempo real. Sua segurança é prioridade máxima com tecnologia de ponta.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
<div class="col-lg-4 col-md-6 col-12">
|
|
||||||
<div class="d-flex align-items-start">
|
|
||||||
<i class="bi bi-credit-card-2-front-fill display-5 me-4 text-success-feature flex-shrink-0"></i>
|
|
||||||
<div>
|
|
||||||
<h4 class="fw-bold mb-1 text-dark-feature">Central de Cartões</h4>
|
|
||||||
<p class="text-secondary-feature">Controle seus limites, bloqueie e desbloqueie cartões virtuais e físicos diretamente pelo app, com total autonomia.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
</main>
|
|
||||||
<FooterPublic />
|
|
||||||
</template>
|
|
||||||
<style scoped>
|
|
||||||
|
|
||||||
.main-no-gap {
|
|
||||||
margin-top: 0;
|
|
||||||
padding-top: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.bg-especial {
|
|
||||||
background-color: #122942;
|
|
||||||
}
|
|
||||||
.btn-success {
|
|
||||||
background-color: #2ECC71;
|
|
||||||
border-color: #2ECC71;
|
|
||||||
}
|
|
||||||
.btn-success:hover {
|
|
||||||
background-color: #26a95f;
|
|
||||||
border-color: #26a95f;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.text-secondary {
|
|
||||||
color: #D1D5DB !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.text-primary-dark {
|
|
||||||
color: #1A3B5E !important;
|
|
||||||
}
|
|
||||||
.text-success-feature {
|
|
||||||
color: #2ECC71 !important;
|
|
||||||
}
|
|
||||||
.text-dark-feature {
|
|
||||||
color: #343a40 !important;
|
|
||||||
}
|
|
||||||
.text-secondary-feature {
|
|
||||||
color: #6c757d !important;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
<template>
|
|
||||||
<HeaderPublic />
|
|
||||||
<div class="login-container d-flex align-items-center justify-content-center min-vh-100 p-3">
|
|
||||||
|
|
||||||
<div class="login-card p-5 shadow-lg rounded-4 bg-white">
|
|
||||||
|
|
||||||
<div class="text-center mb-4">
|
|
||||||
<img src="@/assets/CtrlCash-blue.png" alt="CtrlCash Logo" width="150" class="mb-3">
|
|
||||||
<h2 class="fw-bold text-primary-dark">Login</h2>
|
|
||||||
<p class="text-secondary-dark">Insira seus dados para continuar o controle.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form @submit.prevent="handleLogin">
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="email" class="form-label fw-medium text-primary-dark">E-mail</label>
|
|
||||||
<div class="input-group">
|
|
||||||
<span class="input-group-text"><i class="bi bi-envelope-fill"></i></span>
|
|
||||||
<input type="email" class="form-control" id="email" v-model="loginForm.email" required placeholder="seu.email@exemplo.com">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-4">
|
|
||||||
<label for="password" class="form-label fw-medium text-primary-dark">Senha</label>
|
|
||||||
<div class="input-group">
|
|
||||||
<span class="input-group-text"><i class="bi bi-lock-fill"></i></span>
|
|
||||||
<input type="password" class="form-control" id="password" v-model="loginForm.password" required placeholder="********">
|
|
||||||
</div>
|
|
||||||
<div class="text-end mt-2">
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Mensagem de erro -->
|
|
||||||
<div v-if="error" class="alert alert-danger">
|
|
||||||
{{ error }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button type="submit" class="btn btn-primary-feature w-100 fw-bold py-2 shadow-sm" :disabled="loading">
|
|
||||||
<span v-if="loading" class="spinner-border spinner-border-sm me-2"></span>
|
|
||||||
{{ loading ? 'Entrando...' : 'Acessar Minha Conta' }}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { ref } from 'vue';
|
|
||||||
import { useRouter } from 'vue-router';
|
|
||||||
import HeaderPublic from '@/components/HeaderPublic.vue';
|
|
||||||
|
|
||||||
const router = useRouter();
|
|
||||||
const loading = ref(false);
|
|
||||||
const error = ref('');
|
|
||||||
|
|
||||||
const loginForm = ref({
|
|
||||||
email: '',
|
|
||||||
password: ''
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleLogin = async () => {
|
|
||||||
loading.value = true;
|
|
||||||
error.value = '';
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/auth/login', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify(loginForm.value)
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
// Salvar usuário no localStorage
|
|
||||||
localStorage.setItem('user', JSON.stringify(data.user));
|
|
||||||
localStorage.setItem('isAuthenticated', 'true');
|
|
||||||
|
|
||||||
// Redirecionar para dashboard
|
|
||||||
router.push('/dashboard');
|
|
||||||
} else {
|
|
||||||
error.value = data.error || 'Erro ao fazer login';
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Erro:', err);
|
|
||||||
error.value = 'Erro de conexão com o servidor';
|
|
||||||
} finally {
|
|
||||||
loading.value = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.text-primary-dark { color: #1A3B5E !important; }
|
|
||||||
.text-secondary-dark { color: #6c757d !important; }
|
|
||||||
|
|
||||||
.login-container { background-color: #F8F9FA; }
|
|
||||||
.login-card { max-width: 420px; width: 100%; }
|
|
||||||
|
|
||||||
.btn-primary-feature {
|
|
||||||
background-color: #1A3B5E;
|
|
||||||
border-color: #1A3B5E;
|
|
||||||
color: white;
|
|
||||||
transition: background-color 0.2s;
|
|
||||||
}
|
|
||||||
.btn-primary-feature:hover:not(:disabled) {
|
|
||||||
background-color: #29517b;
|
|
||||||
border-color: #29517b;
|
|
||||||
}
|
|
||||||
.btn-primary-feature:disabled {
|
|
||||||
opacity: 0.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-control:focus {
|
|
||||||
border-color: #1A3B5E;
|
|
||||||
box-shadow: 0 0 0 0.25rem rgba(26, 59, 94, 0.25);
|
|
||||||
}
|
|
||||||
.input-group-text {
|
|
||||||
background-color: #e9ecef;
|
|
||||||
border-right: none;
|
|
||||||
color: #1A3B5E;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hover-link:hover { text-decoration: underline !important; }
|
|
||||||
</style>
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="sobre-nos-page bg-light min-vh-100">
|
|
||||||
|
|
||||||
<!-- Header Público (Links para Login/Cadastro) -->
|
|
||||||
<HeaderPublic />
|
|
||||||
|
|
||||||
<div class="container-fluid py-5 max-w-7xl mx-auto px-4">
|
|
||||||
|
|
||||||
<div class="row g-5 align-items-center">
|
|
||||||
<!-- Seção de Texto (Sobre a Missão) -->
|
|
||||||
<div class="col-lg-6">
|
|
||||||
<h1 class="display-5 fw-bold text-primary-dark mb-4">
|
|
||||||
Nossa Missão: <span class="text-success-feature">Empoderar</span> Suas Finanças.
|
|
||||||
</h1>
|
|
||||||
<p class="lead text-muted">
|
|
||||||
O CtrlCash nasceu da necessidade de simplificar a gestão financeira pessoal. Acreditamos que controlar suas finanças não deve ser um fardo, mas uma ferramenta para alcançar seus objetivos.
|
|
||||||
</p>
|
|
||||||
<p class="text-muted">
|
|
||||||
Nosso sistema oferece uma visão clara e organizada de onde seu dinheiro está indo, permitindo que você tome decisões informadas. Não se trata apenas de registrar gastos, mas de planejar o futuro.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<router-link to="/cadastro" class="btn btn-lg btn-success-feature shadow-lg mt-4">
|
|
||||||
Comece a Controlar Sua Vida Financeira Hoje
|
|
||||||
</router-link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Seção de Imagem/Visual -->
|
|
||||||
<div class="col-lg-6 d-none d-lg-block">
|
|
||||||
<div class="p-5 bg-white shadow-lg rounded-5 text-center border">
|
|
||||||
<i class="bi bi-wallet2 display-1 text-primary-dark mb-3"></i>
|
|
||||||
<h3 class="fw-bold text-primary-dark">Controle Financeiro Descomplicado</h3>
|
|
||||||
<p class="text-muted">A plataforma ideal para quem busca clareza e inteligência nos gastos diários.</p>
|
|
||||||
<i class="bi bi-graph-up display-4 text-success-feature mt-3"></i>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- FOOTER SIMPLES -->
|
|
||||||
<FooterPublic />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import FooterPublic from '@/components/FooterPublic.vue'
|
|
||||||
import HeaderPublic from '@/components/HeaderPublic.vue'
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
/* Estilos Customizados do CtrlCash (Garantindo que o TXT funcione) */
|
|
||||||
.bg-primary-dark { background-color: #1A3B5E !important; }
|
|
||||||
.text-primary-dark { color: #1A3B5E !important; }
|
|
||||||
.text-success-feature { color: #2ECC71 !important; }
|
|
||||||
.btn-success-feature { background-color: #2ECC71 !important; border-color: #2ECC71 !important; color: white; }
|
|
||||||
|
|
||||||
.max-w-7xl { max-width: 80rem; }
|
|
||||||
.mx-auto { margin-left: auto !important; margin-right: auto !important; }
|
|
||||||
</style>
|
|
||||||
@@ -1,623 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="transacoes-page bg-light min-vh-100">
|
|
||||||
|
|
||||||
<!-- Componente do Header -->
|
|
||||||
<HeaderApp />
|
|
||||||
|
|
||||||
<div class="container-fluid py-4 max-w-7xl mx-auto px-4">
|
|
||||||
|
|
||||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
|
||||||
<h1 class="h3 fw-bold text-primary-dark">Minhas Transações</h1>
|
|
||||||
|
|
||||||
<!-- Botão para abrir o Modal de Nova Transação -->
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn btn-success-feature fw-bold shadow-sm d-flex align-items-center"
|
|
||||||
@click="openAddModal"
|
|
||||||
>
|
|
||||||
<i class="bi bi-plus-circle-fill me-2"></i>
|
|
||||||
Nova Transação
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Card de Filtros e Pesquisa -->
|
|
||||||
<div class="card shadow-sm border-0 mb-4 p-3">
|
|
||||||
<div class="row g-3 align-items-center">
|
|
||||||
|
|
||||||
<!-- Filtro por Tipo -->
|
|
||||||
<div class="col-md-3">
|
|
||||||
<label for="filtroTipo" class="form-label small text-muted">Tipo</label>
|
|
||||||
<select id="filtroTipo" v-model="filters.type" class="form-select" @change="loadTransactions">
|
|
||||||
<option value="">Todos</option>
|
|
||||||
<option value="income">Receitas</option>
|
|
||||||
<option value="expense">Despesas</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Filtro por Categoria -->
|
|
||||||
<div class="col-md-4">
|
|
||||||
<label for="filtroCategoria" class="form-label small text-muted">Categoria</label>
|
|
||||||
<select id="filtroCategoria" v-model="filters.category" class="form-select" @change="loadTransactions">
|
|
||||||
<option value="">Todas as categorias</option>
|
|
||||||
<option v-for="category in categories" :key="category.id" :value="category.name">
|
|
||||||
{{ category.name }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Filtro por Período -->
|
|
||||||
<div class="col-md-5">
|
|
||||||
<label for="filtroPeriodo" class="form-label small text-muted">Período</label>
|
|
||||||
<div class="input-group">
|
|
||||||
<input type="date" v-model="filters.startDate" class="form-control" @change="loadTransactions">
|
|
||||||
<span class="input-group-text">a</span>
|
|
||||||
<input type="date" v-model="filters.endDate" class="form-control" @change="loadTransactions">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Tabela de Transações -->
|
|
||||||
<div class="card shadow-sm border-0">
|
|
||||||
<div class="card-body p-0">
|
|
||||||
|
|
||||||
<!-- Loading State -->
|
|
||||||
<div v-if="loading" class="text-center py-5">
|
|
||||||
<div class="spinner-border text-primary-dark" role="status">
|
|
||||||
<span class="visually-hidden">Carregando...</span>
|
|
||||||
</div>
|
|
||||||
<p class="text-muted mt-2">Carregando transações...</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Tabela com Dados -->
|
|
||||||
<div v-else class="table-responsive">
|
|
||||||
<table class="table table-hover mb-0">
|
|
||||||
<thead class="bg-light">
|
|
||||||
<tr>
|
|
||||||
<th scope="col" class="text-primary-dark fw-bold">Descrição</th>
|
|
||||||
<th scope="col" class="text-primary-dark fw-bold">Categoria</th>
|
|
||||||
<th scope="col" class="text-primary-dark fw-bold text-center">Tipo</th>
|
|
||||||
<th scope="col" class="text-primary-dark fw-bold text-end">Valor</th>
|
|
||||||
<th scope="col" class="text-primary-dark fw-bold">Data</th>
|
|
||||||
<th scope="col" class="text-primary-dark fw-bold text-center">Ações</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<!-- Transações Dinâmicas -->
|
|
||||||
<tr v-for="transaction in transactions" :key="transaction.id">
|
|
||||||
<td>{{ transaction.description }}</td>
|
|
||||||
<td>
|
|
||||||
<span class="badge" :style="{
|
|
||||||
backgroundColor: getCategoryColor(transaction.category),
|
|
||||||
color: 'white'
|
|
||||||
}">
|
|
||||||
{{ transaction.category }}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td class="text-center">
|
|
||||||
<span
|
|
||||||
class="badge"
|
|
||||||
:class="transaction.type === 'income' ? 'bg-success-feature' : 'bg-danger'"
|
|
||||||
>
|
|
||||||
<i :class="transaction.type === 'income' ? 'bi bi-arrow-up' : 'bi bi-arrow-down'"></i>
|
|
||||||
{{ transaction.type === 'income' ? 'Receita' : 'Despesa' }}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td class="text-end fw-bold" :class="transaction.type === 'income' ? 'text-success-feature' : 'text-danger'">
|
|
||||||
{{ formatCurrency(transaction.amount) }}
|
|
||||||
</td>
|
|
||||||
<td>{{ formatDate(transaction.date) }}</td>
|
|
||||||
<td class="text-center">
|
|
||||||
<!-- ✅ BOTÃO DE DELETAR VISÍVEL -->
|
|
||||||
<button
|
|
||||||
class="btn btn-sm btn-outline-danger"
|
|
||||||
title="Excluir transação"
|
|
||||||
@click="deleteTransaction(transaction.id)"
|
|
||||||
>
|
|
||||||
<i class="bi bi-trash"></i>
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
|
|
||||||
<!-- Estado Vazio -->
|
|
||||||
<tr v-if="transactions.length === 0 && !loading">
|
|
||||||
<td colspan="6" class="text-center py-5 text-muted">
|
|
||||||
<i class="bi bi-receipt fs-1 opacity-50 d-block mb-2"></i>
|
|
||||||
Nenhuma transação encontrada
|
|
||||||
<br>
|
|
||||||
<small>Clique em "Nova Transação" para adicionar sua primeira transação</small>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Footer com Contador -->
|
|
||||||
<div class="card-footer bg-white text-center" v-if="transactions.length > 0">
|
|
||||||
<small class="text-muted">Exibindo {{ transactions.length }} transações</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Modal de Nova Transação -->
|
|
||||||
<div v-if="showAddModal" class="modal-overlay">
|
|
||||||
<div class="modal-container">
|
|
||||||
<div class="modal-content">
|
|
||||||
<div class="modal-header bg-primary-dark text-white">
|
|
||||||
<h5 class="modal-title fw-bold">
|
|
||||||
Adicionar Nova Transação
|
|
||||||
</h5>
|
|
||||||
<button type="button" class="btn-close btn-close-white" @click="closeModal"></button>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body">
|
|
||||||
<form @submit.prevent="saveTransaction">
|
|
||||||
|
|
||||||
<!-- Tipo de Transação (Receita/Despesa) -->
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label fw-medium text-primary-dark">Tipo</label>
|
|
||||||
<div class="btn-group w-100" role="group">
|
|
||||||
<input type="radio" class="btn-check" v-model="newTransaction.type" id="tipoReceita" value="income">
|
|
||||||
<label class="btn btn-outline-success-feature fw-bold" for="tipoReceita"
|
|
||||||
:class="{ 'active': newTransaction.type === 'income' }">
|
|
||||||
Receita
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<input type="radio" class="btn-check" v-model="newTransaction.type" id="tipoDespesa" value="expense">
|
|
||||||
<label class="btn btn-outline-danger fw-bold" for="tipoDespesa"
|
|
||||||
:class="{ 'active': newTransaction.type === 'expense' }">
|
|
||||||
Despesa
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Descrição -->
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="descricao" class="form-label fw-medium text-primary-dark">Descrição</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
class="form-control"
|
|
||||||
id="descricao"
|
|
||||||
v-model="newTransaction.description"
|
|
||||||
placeholder="Ex: Salário, Aluguel, Supermercado"
|
|
||||||
required
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Valor e Data -->
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-6 mb-3">
|
|
||||||
<label for="valor" class="form-label fw-medium text-primary-dark">Valor (R$)</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
step="0.01"
|
|
||||||
min="0.01"
|
|
||||||
class="form-control"
|
|
||||||
id="valor"
|
|
||||||
v-model="newTransaction.amount"
|
|
||||||
placeholder="0.00"
|
|
||||||
required
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-6 mb-3">
|
|
||||||
<label for="data" class="form-label fw-medium text-primary-dark">Data</label>
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
class="form-control"
|
|
||||||
id="data"
|
|
||||||
v-model="newTransaction.date"
|
|
||||||
required
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Categoria -->
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="categoria" class="form-label fw-medium text-primary-dark">Categoria</label>
|
|
||||||
<select
|
|
||||||
id="categoria"
|
|
||||||
class="form-select"
|
|
||||||
v-model="newTransaction.category"
|
|
||||||
required
|
|
||||||
>
|
|
||||||
<option value="">Selecione uma categoria...</option>
|
|
||||||
<option
|
|
||||||
v-for="category in filteredCategories"
|
|
||||||
:key="category.id"
|
|
||||||
:value="category.name"
|
|
||||||
>
|
|
||||||
{{ category.name }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Mensagens de Erro/Sucesso -->
|
|
||||||
<div v-if="message" class="alert" :class="message.type === 'success' ? 'alert-success' : 'alert-danger'">
|
|
||||||
{{ message.text }}
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer">
|
|
||||||
<button type="button" class="btn btn-secondary" @click="closeModal">Cancelar</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn btn-primary-dark-feature fw-bold"
|
|
||||||
@click="saveTransaction"
|
|
||||||
:disabled="saving"
|
|
||||||
>
|
|
||||||
<span v-if="saving" class="spinner-border spinner-border-sm me-2"></span>
|
|
||||||
{{ saving ? 'Salvando...' : 'Salvar Transação' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { ref, onMounted, computed } from 'vue';
|
|
||||||
import { useRouter } from 'vue-router';
|
|
||||||
import HeaderApp from '@/components/HeaderApp.vue';
|
|
||||||
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
// Estados reativos
|
|
||||||
const transactions = ref([]);
|
|
||||||
const categories = ref([]);
|
|
||||||
const loading = ref(false);
|
|
||||||
const saving = ref(false);
|
|
||||||
const showAddModal = ref(false);
|
|
||||||
|
|
||||||
const filters = ref({
|
|
||||||
type: '',
|
|
||||||
category: '',
|
|
||||||
startDate: '',
|
|
||||||
endDate: ''
|
|
||||||
});
|
|
||||||
|
|
||||||
const newTransaction = ref({
|
|
||||||
type: 'expense',
|
|
||||||
description: '',
|
|
||||||
amount: '',
|
|
||||||
category: '',
|
|
||||||
date: new Date().toISOString().split('T')[0]
|
|
||||||
});
|
|
||||||
|
|
||||||
const message = ref(null);
|
|
||||||
|
|
||||||
// Computed
|
|
||||||
const filteredCategories = computed(() => {
|
|
||||||
return categories.value.filter(cat => cat.type === newTransaction.value.type);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Métodos
|
|
||||||
const loadTransactions = async () => {
|
|
||||||
loading.value = true;
|
|
||||||
try {
|
|
||||||
const user = JSON.parse(localStorage.getItem('user'));
|
|
||||||
if (!user) {
|
|
||||||
router.push('/login');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(`/api/transactions?user_id=${user.id}`);
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
let filtered = data.transactions;
|
|
||||||
|
|
||||||
// Aplicar filtros
|
|
||||||
if (filters.value.type) {
|
|
||||||
filtered = filtered.filter(t => t.type === filters.value.type);
|
|
||||||
}
|
|
||||||
if (filters.value.category) {
|
|
||||||
filtered = filtered.filter(t => t.category === filters.value.category);
|
|
||||||
}
|
|
||||||
if (filters.value.startDate) {
|
|
||||||
filtered = filtered.filter(t => t.date >= filters.value.startDate);
|
|
||||||
}
|
|
||||||
if (filters.value.endDate) {
|
|
||||||
filtered = filtered.filter(t => t.date <= filters.value.endDate);
|
|
||||||
}
|
|
||||||
|
|
||||||
transactions.value = filtered;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Erro ao carregar transações:', error);
|
|
||||||
showMessage('Erro ao carregar transações', 'error');
|
|
||||||
} finally {
|
|
||||||
loading.value = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const loadCategories = async () => {
|
|
||||||
try {
|
|
||||||
const user = JSON.parse(localStorage.getItem('user'));
|
|
||||||
const response = await fetch(`/api/categories?user_id=${user.id}`);
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
categories.value = data.categories;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Erro ao carregar categorias:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const saveTransaction = async () => {
|
|
||||||
saving.value = true;
|
|
||||||
message.value = null;
|
|
||||||
|
|
||||||
// Validações
|
|
||||||
if (!newTransaction.value.description.trim()) {
|
|
||||||
showMessage('Descrição é obrigatória', 'error');
|
|
||||||
saving.value = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!newTransaction.value.amount || parseFloat(newTransaction.value.amount) <= 0) {
|
|
||||||
showMessage('Valor deve ser maior que zero', 'error');
|
|
||||||
saving.value = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!newTransaction.value.category) {
|
|
||||||
showMessage('Selecione uma categoria', 'error');
|
|
||||||
saving.value = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const user = JSON.parse(localStorage.getItem('user'));
|
|
||||||
|
|
||||||
const transactionData = {
|
|
||||||
user_id: user.id,
|
|
||||||
amount: parseFloat(newTransaction.value.amount),
|
|
||||||
description: newTransaction.value.description.trim(),
|
|
||||||
category: newTransaction.value.category,
|
|
||||||
type: newTransaction.value.type,
|
|
||||||
date: newTransaction.value.date
|
|
||||||
};
|
|
||||||
|
|
||||||
const response = await fetch('/api/transactions', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify(transactionData)
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
showMessage('Transação salva com sucesso!', 'success');
|
|
||||||
closeModal();
|
|
||||||
loadTransactions();
|
|
||||||
} else {
|
|
||||||
showMessage(data.error || 'Erro ao salvar transação', 'error');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Erro:', error);
|
|
||||||
showMessage('Erro de conexão com o servidor', 'error');
|
|
||||||
} finally {
|
|
||||||
saving.value = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const deleteTransaction = async (transactionId) => {
|
|
||||||
if (!confirm('Tem certeza que deseja excluir esta transação?')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const user = JSON.parse(localStorage.getItem('user'));
|
|
||||||
const response = await fetch(`/api/transactions/${transactionId}?user_id=${user.id}`, {
|
|
||||||
method: 'DELETE'
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
showMessage('Transação excluída com sucesso!', 'success');
|
|
||||||
loadTransactions();
|
|
||||||
} else {
|
|
||||||
const data = await response.json();
|
|
||||||
showMessage(data.error || 'Erro ao excluir transação', 'error');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Erro:', error);
|
|
||||||
showMessage('Erro de conexão com o servidor', 'error');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const openAddModal = () => {
|
|
||||||
showAddModal.value = true;
|
|
||||||
resetNewTransaction();
|
|
||||||
message.value = null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const closeModal = () => {
|
|
||||||
showAddModal.value = false;
|
|
||||||
resetNewTransaction();
|
|
||||||
message.value = null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const resetNewTransaction = () => {
|
|
||||||
newTransaction.value = {
|
|
||||||
type: 'expense',
|
|
||||||
description: '',
|
|
||||||
amount: '',
|
|
||||||
category: '',
|
|
||||||
date: new Date().toISOString().split('T')[0]
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const showMessage = (text, type) => {
|
|
||||||
message.value = { text, type };
|
|
||||||
setTimeout(() => {
|
|
||||||
message.value = null;
|
|
||||||
}, 5000);
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatCurrency = (value) => {
|
|
||||||
return new Intl.NumberFormat('pt-BR', {
|
|
||||||
style: 'currency',
|
|
||||||
currency: 'BRL'
|
|
||||||
}).format(value || 0);
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatDate = (dateString) => {
|
|
||||||
if (!dateString) return '-';
|
|
||||||
|
|
||||||
// Converter YYYY-MM-DD para DD/MM/YYYY
|
|
||||||
if (dateString.match(/^\d{4}-\d{2}-\d{2}$/)) {
|
|
||||||
const [year, month, day] = dateString.split('-');
|
|
||||||
return `${day.padStart(2, '0')}/${month.padStart(2, '0')}/${year}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return dateString;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getCategoryColor = (categoryName) => {
|
|
||||||
const category = categories.value.find(cat => cat.name === categoryName);
|
|
||||||
return category ? category.color : '#6c757d';
|
|
||||||
};
|
|
||||||
|
|
||||||
// Lifecycle
|
|
||||||
onMounted(() => {
|
|
||||||
loadTransactions();
|
|
||||||
loadCategories();
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
/* Definição das cores customizadas para o Bootstrap */
|
|
||||||
.bg-primary-dark { background-color: #1A3B5E !important; }
|
|
||||||
.text-primary-dark { color: #1A3B5E !important; }
|
|
||||||
|
|
||||||
.bg-success-feature { background-color: #2ECC71 !important; }
|
|
||||||
.text-success-feature { color: #2ECC71 !important; }
|
|
||||||
|
|
||||||
.btn-outline-success-feature {
|
|
||||||
--bs-btn-color: #2ECC71;
|
|
||||||
--bs-btn-border-color: #2ECC71;
|
|
||||||
--bs-btn-hover-bg: #2ECC71;
|
|
||||||
--bs-btn-hover-border-color: #2ECC71;
|
|
||||||
--bs-btn-active-bg: #2ECC71;
|
|
||||||
--bs-btn-active-border-color: #2ECC71;
|
|
||||||
--bs-btn-active-color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary-dark-feature {
|
|
||||||
background-color: #1A3B5E;
|
|
||||||
color: white;
|
|
||||||
border-color: #1A3B5E;
|
|
||||||
}
|
|
||||||
.btn-primary-dark-feature:hover:not(:disabled) {
|
|
||||||
background-color: #122841;
|
|
||||||
border-color: #122841;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
.btn-primary-dark-feature:disabled {
|
|
||||||
opacity: 0.6;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Utilitário para limitar largura */
|
|
||||||
.max-w-7xl {
|
|
||||||
max-width: 80rem;
|
|
||||||
}
|
|
||||||
.mx-auto {
|
|
||||||
margin-left: auto !important;
|
|
||||||
margin-right: auto !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Modal Customizado */
|
|
||||||
.modal-overlay {
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
background-color: rgba(0, 0, 0, 0.5);
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
z-index: 1000;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-container {
|
|
||||||
width: 100%;
|
|
||||||
max-width: 500px;
|
|
||||||
margin: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-content {
|
|
||||||
background: white;
|
|
||||||
border-radius: 8px;
|
|
||||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3);
|
|
||||||
animation: modalAppear 0.3s ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes modalAppear {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(-20px);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-header {
|
|
||||||
border-bottom: 1px solid #dee2e6;
|
|
||||||
padding: 1rem 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-body {
|
|
||||||
padding: 1.5rem;
|
|
||||||
max-height: 70vh;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-footer {
|
|
||||||
border-top: 1px solid #dee2e6;
|
|
||||||
padding: 1rem 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Estilo para botões ativos */
|
|
||||||
.btn-group .btn.active {
|
|
||||||
background-color: inherit;
|
|
||||||
border-color: inherit;
|
|
||||||
color: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-outline-success-feature.active {
|
|
||||||
background-color: #2ECC71;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-outline-danger.active {
|
|
||||||
background-color: #dc3545;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Estilos para badges */
|
|
||||||
.badge {
|
|
||||||
font-size: 0.75em;
|
|
||||||
padding: 0.35em 0.65em;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Botão de deletar */
|
|
||||||
.btn-outline-danger {
|
|
||||||
border-color: #dc3545;
|
|
||||||
color: #dc3545;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-outline-danger:hover {
|
|
||||||
background-color: #dc3545;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,239 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="dashboard-page bg-light min-vh-100">
|
|
||||||
<HeaderApp />
|
|
||||||
<div class="container-fluid py-4 max-w-7xl mx-auto px-4">
|
|
||||||
|
|
||||||
<h1 class="h3 fw-bold text-primary-dark mb-4">Visão Geral</h1>
|
|
||||||
|
|
||||||
<!-- Sumário (Cards de Resumo) -->
|
|
||||||
<div class="row g-4 mb-5">
|
|
||||||
<div class="col-lg-4 col-md-6 col-sm-12">
|
|
||||||
<div class="card shadow-sm border-0 h-100 p-3">
|
|
||||||
<div class="card-body">
|
|
||||||
<h5 class="card-title text-muted mb-3">Saldo Total</h5>
|
|
||||||
<div class="d-flex justify-content-between align-items-center">
|
|
||||||
<p class="card-text fs-3 fw-bold text-primary-dark">{{ formatCurrency(summary.balance) }}</p>
|
|
||||||
<i class="bi bi-wallet2 fs-2 text-primary-dark opacity-75"></i>
|
|
||||||
</div>
|
|
||||||
<small :class="summary.balance >= 0 ? 'text-success-feature' : 'text-danger'" class="fw-medium">
|
|
||||||
{{ getBalanceTrend() }}
|
|
||||||
</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-lg-4 col-md-6 col-sm-12">
|
|
||||||
<div class="card shadow-sm border-0 h-100 p-3">
|
|
||||||
<div class="card-body">
|
|
||||||
<h5 class="card-title text-muted mb-3">Receitas do Mês</h5>
|
|
||||||
<div class="d-flex justify-content-between align-items-center">
|
|
||||||
<p class="card-text fs-3 fw-bold text-success-feature">{{ formatCurrency(summary.total_income) }}</p>
|
|
||||||
<i class="bi bi-arrow-up-circle-fill fs-2 text-success-feature opacity-75"></i>
|
|
||||||
</div>
|
|
||||||
<small class="text-muted">{{ getIncomeProgress() }}</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-lg-4 col-md-12 col-sm-12">
|
|
||||||
<div class="card shadow-sm border-0 h-100 p-3">
|
|
||||||
<div class="card-body">
|
|
||||||
<h5 class="card-title text-muted mb-3">Despesas do Mês</h5>
|
|
||||||
<div class="d-flex justify-content-between align-items-center">
|
|
||||||
<p class="card-text fs-3 fw-bold text-danger">{{ formatCurrency(summary.total_expenses) }}</p>
|
|
||||||
<i class="bi bi-arrow-down-circle-fill fs-2 text-danger opacity-75"></i>
|
|
||||||
</div>
|
|
||||||
<small class="text-muted">{{ getExpenseLimit() }}</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Gráfico e Atividade Recente -->
|
|
||||||
<div class="row g-4">
|
|
||||||
<div class="col-lg-8">
|
|
||||||
<div class="card shadow-sm border-0 h-100 p-4">
|
|
||||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
|
||||||
<h4 class="fw-bold text-primary-dark mb-0">Evolução Financeira</h4>
|
|
||||||
<div class="btn-group btn-group-sm">
|
|
||||||
<button @click="loadChartData('monthly')" class="btn btn-outline-primary" :class="{ 'active': chartType === 'monthly' }">
|
|
||||||
Mensal
|
|
||||||
</button>
|
|
||||||
<button @click="loadChartData('categories')" class="btn btn-outline-primary" :class="{ 'active': chartType === 'categories' }">
|
|
||||||
Categorias
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="chartImage" class="chart-container rounded-3 text-center">
|
|
||||||
<img :src="chartImage" alt="Gráfico Financeiro" class="img-fluid rounded" style="max-height: 400px;">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else class="matplotlib-placeholder bg-light-subtle rounded-3 border border-dashed p-5 text-center d-flex flex-column align-items-center justify-content-center" style="min-height: 400px;">
|
|
||||||
<div v-if="loadingChart" class="spinner-border text-primary-dark mb-3" role="status"></div>
|
|
||||||
<i class="bi bi-bar-chart-fill fs-1 text-primary-dark opacity-25 mb-2"></i>
|
|
||||||
<p class="text-primary-dark opacity-50 mb-0">
|
|
||||||
{{ loadingChart ? 'Carregando visualização...' : 'Selecione um tipo de gráfico' }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-lg-4">
|
|
||||||
<div class="card shadow-sm border-0 h-100 p-4">
|
|
||||||
<h4 class="fw-bold text-primary-dark mb-3">Atividade Recente</h4>
|
|
||||||
|
|
||||||
<div v-if="loadingTransactions" class="text-center py-4">
|
|
||||||
<div class="spinner-border text-primary-dark" role="status"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ul v-else class="list-group list-group-flush">
|
|
||||||
<li v-for="transaction in summary.recent_transactions" :key="transaction.id" class="list-group-item d-flex justify-content-between align-items-center">
|
|
||||||
<div class="transaction-info">
|
|
||||||
<div class="fw-medium">{{ transaction.description }}</div>
|
|
||||||
<small class="text-muted">
|
|
||||||
{{ formatDate(transaction.date) }} • {{ transaction.category }}
|
|
||||||
</small>
|
|
||||||
</div>
|
|
||||||
<span :class="transaction.type === 'income' ? 'badge bg-success-feature' : 'badge bg-danger'">
|
|
||||||
{{ transaction.type === 'income' ? '+' : '-' }}{{ formatCurrency(transaction.amount) }}
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
|
|
||||||
<li v-if="summary.recent_transactions && summary.recent_transactions.length === 0" class="list-group-item text-center text-muted py-4">
|
|
||||||
<i class="bi bi-receipt fs-4 opacity-50 d-block mb-2"></i>
|
|
||||||
Nenhuma transação recente
|
|
||||||
</li>
|
|
||||||
|
|
||||||
<li class="list-group-item text-center pt-3 border-0">
|
|
||||||
<router-link to="/transacoes" class="btn btn-link btn-sm text-primary-dark fw-medium">
|
|
||||||
Ver todas as transações
|
|
||||||
</router-link>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { ref, onMounted } from 'vue';
|
|
||||||
import { useRouter } from 'vue-router';
|
|
||||||
import HeaderApp from '@/components/HeaderApp.vue';
|
|
||||||
|
|
||||||
const router = useRouter();
|
|
||||||
const summary = ref({
|
|
||||||
total_income: 0,
|
|
||||||
total_expenses: 0,
|
|
||||||
balance: 0,
|
|
||||||
recent_transactions: []
|
|
||||||
});
|
|
||||||
const chartImage = ref('');
|
|
||||||
const chartType = ref('monthly');
|
|
||||||
const loadingChart = ref(false);
|
|
||||||
const loadingTransactions = ref(false);
|
|
||||||
|
|
||||||
const loadDashboardData = async () => {
|
|
||||||
loadingTransactions.value = true;
|
|
||||||
try {
|
|
||||||
const user = JSON.parse(localStorage.getItem('user'));
|
|
||||||
if (!user) {
|
|
||||||
router.push('/login');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(`/api/dashboard/summary?user_id=${user.id}`);
|
|
||||||
if (response.ok) {
|
|
||||||
const data = await response.json();
|
|
||||||
summary.value = data;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Erro ao carregar dashboard:', error);
|
|
||||||
} finally {
|
|
||||||
loadingTransactions.value = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const loadChartData = async (type = 'monthly') => {
|
|
||||||
loadingChart.value = true;
|
|
||||||
chartType.value = type;
|
|
||||||
try {
|
|
||||||
const user = JSON.parse(localStorage.getItem('user'));
|
|
||||||
const response = await fetch(`/api/dashboard/chart?user_id=${user.id}&type=${type}`);
|
|
||||||
if (response.ok) {
|
|
||||||
const data = await response.json();
|
|
||||||
chartImage.value = data.chart;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Erro ao carregar gráfico:', error);
|
|
||||||
} finally {
|
|
||||||
loadingChart.value = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatCurrency = (value) => {
|
|
||||||
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(value || 0);
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatDate = (dateString) => {
|
|
||||||
if (!dateString) return '-';
|
|
||||||
|
|
||||||
// ✅ MESMA CORREÇÃO: Converter YYYY-MM-DD para DD/MM/YYYY
|
|
||||||
if (dateString.match(/^\d{4}-\d{2}-\d{2}$/)) {
|
|
||||||
const [year, month, day] = dateString.split('-');
|
|
||||||
return `${day.padStart(2, '0')}/${month.padStart(2, '0')}/${year}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Se já está formatada, retorna como está
|
|
||||||
return dateString;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getBalanceTrend = () => {
|
|
||||||
const balance = summary.value.balance;
|
|
||||||
if (balance > 0) return 'Saldo positivo';
|
|
||||||
if (balance < 0) return 'Saldo negativo';
|
|
||||||
return 'Saldo zerado';
|
|
||||||
};
|
|
||||||
|
|
||||||
const getIncomeProgress = () => {
|
|
||||||
const income = summary.value.total_income;
|
|
||||||
if (income === 0) return 'Sem receitas este mês';
|
|
||||||
const target = 5000;
|
|
||||||
const progress = (income / target) * 100;
|
|
||||||
return `Meta: ${Math.min(progress, 100).toFixed(0)}%`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getExpenseLimit = () => {
|
|
||||||
const expenses = summary.value.total_expenses;
|
|
||||||
const limit = 2500;
|
|
||||||
const remaining = limit - expenses;
|
|
||||||
return remaining >= 0 ? `Limite restante: ${formatCurrency(remaining)}` : `Excedido: ${formatCurrency(Math.abs(remaining))}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
loadDashboardData();
|
|
||||||
setTimeout(() => loadChartData('monthly'), 500);
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.bg-primary-dark { background-color: #1A3B5E !important; }
|
|
||||||
.text-primary-dark { color: #1A3B5E !important; }
|
|
||||||
.bg-success-feature { background-color: #2ECC71 !important; }
|
|
||||||
.text-success-feature { color: #2ECC71 !important; }
|
|
||||||
|
|
||||||
.max-w-7xl { max-width: 80rem; }
|
|
||||||
.mx-auto { margin-left: auto !important; margin-right: auto !important; }
|
|
||||||
|
|
||||||
.btn-outline-primary.active {
|
|
||||||
background-color: #1A3B5E;
|
|
||||||
border-color: #1A3B5E;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.transaction-info { flex: 1; text-align: left; }
|
|
||||||
.chart-container { background-color: white; border: 1px solid #e9ecef; }
|
|
||||||
.border-dashed { border-style: dashed !important; }
|
|
||||||
</style>
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
import { fileURLToPath, URL } from 'node:url'
|
|
||||||
|
|
||||||
import { defineConfig } from 'vite'
|
|
||||||
import vue from '@vitejs/plugin-vue'
|
|
||||||
import vueDevTools from 'vite-plugin-vue-devtools'
|
|
||||||
|
|
||||||
// https://vite.dev/config/
|
|
||||||
export default defineConfig({
|
|
||||||
plugins: [
|
|
||||||
vue(),
|
|
||||||
vueDevTools(),
|
|
||||||
],
|
|
||||||
resolve: {
|
|
||||||
alias: {
|
|
||||||
'@': fileURLToPath(new URL('./src', import.meta.url))
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
Reference in New Issue
Block a user