Vue final feito pro site
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -13,7 +13,7 @@ dist
|
|||||||
dist-ssr
|
dist-ssr
|
||||||
coverage
|
coverage
|
||||||
*.local
|
*.local
|
||||||
|
venv
|
||||||
# Editor directories and files
|
# Editor directories and files
|
||||||
.vscode/*
|
.vscode/*
|
||||||
!.vscode/extensions.json
|
!.vscode/extensions.json
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
<div class="container-fluid max-w-7xl mx-auto px-4">
|
<div class="container-fluid max-w-7xl mx-auto px-4">
|
||||||
|
|
||||||
<!-- Logo e Branding -->
|
<!-- Logo e Branding -->
|
||||||
<!-- O 'd-flex align-items-center' da navbar deve garantir o alinhamento central -->
|
|
||||||
<router-link to="/dashboard" class="navbar-brand d-flex align-items-center me-4">
|
<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">
|
<img src="@/assets/CtrlCash-white.png" alt="CtrlCash Logo" width="120" class="d-inline-block logo-align">
|
||||||
</router-link>
|
</router-link>
|
||||||
@@ -34,25 +33,36 @@
|
|||||||
<!-- Botão de Notificações -->
|
<!-- Botão de Notificações -->
|
||||||
<button class="btn btn-link text-white me-3 p-0" title="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">
|
<i class="bi bi-bell-fill fs-5 position-relative">
|
||||||
<span class="position-absolute top-0 start-100 translate-middle p-1 bg-danger border border-light rounded-circle"></span>
|
<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>
|
</i>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Avatar do Usuário -->
|
<!-- Avatar e Nome do Usuário -->
|
||||||
<div class="dropdown me-3">
|
<div class="d-flex align-items-center me-3">
|
||||||
<button class="btn btn-link p-0 d-flex align-items-center text-white" type="button" data-bs-toggle="dropdown" aria-expanded="false">
|
<img
|
||||||
<img class="rounded-circle border border-white" src="https://placehold.co/36x36/1A3B5E/FFFFFF?text=JP" alt="Avatar" style="width: 36px; height: 36px; object-fit: cover;">
|
class="rounded-circle border border-white"
|
||||||
<span class="ms-2 d-none d-md-inline text-sm">João Pedro</span>
|
:src="userAvatar"
|
||||||
</button>
|
:alt="`Avatar de ${userName}`"
|
||||||
<!-- Dropdown Menu (Placeholder) -->
|
style="width: 36px; height: 36px; object-fit: cover;"
|
||||||
<!-- <ul class="dropdown-menu">...</ul> -->
|
>
|
||||||
|
<span class="ms-2 d-none d-md-inline text-sm text-white">{{ userName }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Botão de Logout -->
|
<!-- Botão de Logout - Desktop -->
|
||||||
<button @click="logout" class="btn btn-outline-light btn-sm d-none d-sm-inline-flex align-items-center" title="Sair">
|
<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>
|
<i class="bi bi-box-arrow-right me-1"></i>
|
||||||
Sair
|
Sair
|
||||||
</button>
|
</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>
|
</div>
|
||||||
@@ -62,16 +72,71 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { useRouter, useRoute } from 'vue-router';
|
import { useRouter, useRoute } from 'vue-router';
|
||||||
import { ref, watch } from 'vue'; // Importando ref e watch
|
import { ref, onMounted, watch } from 'vue';
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const route = useRoute(); // Obtém o objeto de rota atual
|
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 = () => {
|
const logout = () => {
|
||||||
console.log("Usuário desconectado. Redirecionando para o login.");
|
console.log("Usuário desconectado. Redirecionando para o login.");
|
||||||
// Ação de logout real (ex: limpar token, session) seria aqui.
|
|
||||||
|
// Limpar dados de autenticação
|
||||||
|
localStorage.removeItem('user');
|
||||||
|
localStorage.removeItem('isAuthenticated');
|
||||||
|
localStorage.removeItem('token');
|
||||||
|
|
||||||
|
// Redirecionar para login
|
||||||
router.push('/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>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -81,15 +146,40 @@
|
|||||||
.text-success-feature { color: #2ECC71 !important; }
|
.text-success-feature { color: #2ECC71 !important; }
|
||||||
.hover-success-feature:hover { color: #2ECC71 !important; }
|
.hover-success-feature:hover { color: #2ECC71 !important; }
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/* FORÇA O ALINHAMENTO VERTICAL DA IMAGEM E DO TEXTO */
|
/* FORÇA O ALINHAMENTO VERTICAL DA IMAGEM E DO TEXTO */
|
||||||
.navbar-brand {
|
.navbar-brand {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center; /* Garante que todos os itens no brand estão centralizados */
|
align-items: center;
|
||||||
}
|
}
|
||||||
.logo-align {
|
.logo-align {
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
/* Removendo o ajuste manual para confiar no alinhamento Flexbox do Bootstrap */
|
}
|
||||||
|
|
||||||
|
/* 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>
|
</style>
|
||||||
@@ -1,50 +1,39 @@
|
|||||||
<template>
|
<template>
|
||||||
<HeaderPublic />
|
<HeaderPublic />
|
||||||
<div class="cadastro-container d-flex align-items-center justify-content-center min-vh-100 p-3">
|
<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="cadastro-card p-5 shadow-lg rounded-4 bg-white">
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<div class="text-center mb-4">
|
<div class="text-center mb-4">
|
||||||
|
|
||||||
<img src="@/assets/CtrlCash-blue.png" alt="CtrlCash Logo" width="150" class="mb-3">
|
<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>
|
<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>
|
<p class="text-secondary-dark">Preencha os campos para iniciar seu controle financeiro.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<form @submit.prevent="handleCadastro">
|
<form @submit.prevent="handleCadastro">
|
||||||
|
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="nome" class="form-label fw-medium text-primary-dark">Nome Completo</label>
|
<label for="nome" class="form-label fw-medium text-primary-dark">Nome Completo</label>
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<span class="input-group-text"><i class="bi bi-person-fill"></i></span>
|
<span class="input-group-text"><i class="bi bi-person-fill"></i></span>
|
||||||
<input type="text" class="form-control" id="nome" v-model="nome" required placeholder="Seu nome">
|
<input type="text" class="form-control" id="nome" v-model="cadastroForm.name" required placeholder="Seu nome">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="email" class="form-label fw-medium text-primary-dark">E-mail</label>
|
<label for="email" class="form-label fw-medium text-primary-dark">E-mail</label>
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<span class="input-group-text"><i class="bi bi-envelope-fill"></i></span>
|
<span class="input-group-text"><i class="bi bi-envelope-fill"></i></span>
|
||||||
<input type="email" class="form-control" id="email" v-model="email" required placeholder="seu.email@exemplo.com">
|
<input type="email" class="form-control" id="email" v-model="cadastroForm.email" required placeholder="seu.email@exemplo.com">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="password" class="form-label fw-medium text-primary-dark">Crie Sua Senha</label>
|
<label for="password" class="form-label fw-medium text-primary-dark">Crie Sua Senha</label>
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<span class="input-group-text"><i class="bi bi-lock-fill"></i></span>
|
<span class="input-group-text"><i class="bi bi-lock-fill"></i></span>
|
||||||
<input type="password" class="form-control" id="password" v-model="password" required placeholder="Mínimo 8 caracteres">
|
<input type="password" class="form-control" id="password" v-model="cadastroForm.password" required placeholder="Mínimo 6 caracteres">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label for="confirmPassword" class="form-label fw-medium text-primary-dark">Confirmar Senha</label>
|
<label for="confirmPassword" class="form-label fw-medium text-primary-dark">Confirmar Senha</label>
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
@@ -53,82 +42,97 @@
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<button type="submit" class="btn btn-primary-feature w-100 fw-bold py-2 shadow-sm" :disabled="loading">
|
||||||
Criar Minha Conta CtrlCash
|
<span v-if="loading" class="spinner-border spinner-border-sm me-2"></span>
|
||||||
|
{{ loading ? 'Cadastrando...' : 'Criar Minha Conta CtrlCash' }}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import HeaderPublic from '@/components/HeaderPublic.vue'
|
|
||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
|
import HeaderPublic from '@/components/HeaderPublic.vue';
|
||||||
|
|
||||||
const nome = ref('');
|
|
||||||
const email = ref('');
|
|
||||||
const password = ref('');
|
|
||||||
const confirmPassword = ref('');
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const loading = ref(false);
|
||||||
|
const error = ref('');
|
||||||
|
const success = ref('');
|
||||||
|
|
||||||
const handleCadastro = () => {
|
const cadastroForm = ref({
|
||||||
|
name: '',
|
||||||
|
email: '',
|
||||||
|
password: ''
|
||||||
|
});
|
||||||
|
const confirmPassword = ref('');
|
||||||
|
|
||||||
if (password.value !== confirmPassword.value) {
|
const handleCadastro = async () => {
|
||||||
console.error("As senhas não coincidem!");
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('Tentativa de Cadastro:', nome.value, email.value);
|
if (cadastroForm.value.password.length < 6) {
|
||||||
alertSimulado('Conta criada com sucesso! Faça login para acessar o dashboard.');
|
error.value = "A senha deve ter pelo menos 6 caracteres";
|
||||||
router.push('/login');
|
loading.value = false;
|
||||||
};
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('http://localhost:5000/api/auth/register', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(cadastroForm.value)
|
||||||
|
});
|
||||||
|
|
||||||
const alertSimulado = (message) => {
|
const data = await response.json();
|
||||||
|
|
||||||
console.log(`[ALERTA SIMULADO]: ${message}`);
|
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>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.text-primary-dark { color: #1A3B5E !important; }
|
||||||
|
.text-secondary-dark { color: #6c757d !important; }
|
||||||
|
|
||||||
.text-primary-dark {
|
.cadastro-container { background-color: #F8F9FA; }
|
||||||
color: #1A3B5E !important;
|
.cadastro-card { max-width: 450px; width: 100%; }
|
||||||
}
|
|
||||||
.text-secondary-dark {
|
|
||||||
color: #6c757d !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.cadastro-container {
|
|
||||||
background-color: #F8F9FA;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.cadastro-card {
|
|
||||||
max-width: 450px;
|
|
||||||
width: 100%;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.back-button {
|
|
||||||
position: absolute;
|
|
||||||
top: 20px;
|
|
||||||
left: 20px;
|
|
||||||
text-decoration: none;
|
|
||||||
transition: opacity 0.2s;
|
|
||||||
z-index: 10;
|
|
||||||
}
|
|
||||||
.back-button:hover {
|
|
||||||
opacity: 0.7;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.btn-primary-feature {
|
.btn-primary-feature {
|
||||||
background-color: #1A3B5E;
|
background-color: #1A3B5E;
|
||||||
@@ -136,11 +140,13 @@
|
|||||||
color: white;
|
color: white;
|
||||||
transition: background-color 0.2s;
|
transition: background-color 0.2s;
|
||||||
}
|
}
|
||||||
.btn-primary-feature:hover {
|
.btn-primary-feature:hover:not(:disabled) {
|
||||||
background-color: #29517b;
|
background-color: #29517b;
|
||||||
border-color: #29517b;
|
border-color: #29517b;
|
||||||
}
|
}
|
||||||
|
.btn-primary-feature:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
.form-control:focus {
|
.form-control:focus {
|
||||||
border-color: #1A3B5E;
|
border-color: #1A3B5E;
|
||||||
@@ -151,8 +157,4 @@
|
|||||||
border-right: none;
|
border-right: none;
|
||||||
color: #1A3B5E;
|
color: #1A3B5E;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hover-link:hover {
|
|
||||||
text-decoration: underline !important;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
@@ -39,73 +39,527 @@
|
|||||||
<div v-if="activeTab === 'perfil'">
|
<div v-if="activeTab === 'perfil'">
|
||||||
<h2 class="h4 fw-bold text-primary-dark mb-3">Meu Perfil</h2>
|
<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>
|
<p class="text-muted">Gerencie seu nome, e-mail e outras informações de contato.</p>
|
||||||
<!-- Placeholder de Formulário -->
|
|
||||||
<form>
|
<form @submit.prevent="updateProfile">
|
||||||
<div class="mb-3"><label class="form-label">Nome:</label><input type="text" class="form-control" value="[Nome do Usuário]"></div>
|
<div class="row">
|
||||||
<div class="mb-3"><label class="form-label">E-mail:</label><input type="email" class="form-control" value="[Email do Usuário]" disabled></div>
|
<div class="col-md-6 mb-3">
|
||||||
<button class="btn btn-primary-feature mt-2">Salvar Alterações</button>
|
<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>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Aba: Gestão de Categorias -->
|
<!-- Aba: Gestão de Categorias -->
|
||||||
<div v-if="activeTab === 'categorias'">
|
<div v-if="activeTab === 'categorias'">
|
||||||
<h2 class="h4 fw-bold text-primary-dark mb-3">Categorias Financeiras</h2>
|
<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>
|
<p class="text-muted">Crie, edite ou remova categorias de receitas e despesas.</p>
|
||||||
<button class="btn btn-success-feature mb-3"><i class="bi bi-plus-circle me-2"></i> Nova Categoria</button>
|
|
||||||
<!-- Placeholder de Lista -->
|
<!-- Loading State -->
|
||||||
<ul class="list-group">
|
<div v-if="loadingCategories" class="text-center py-4">
|
||||||
<li class="list-group-item d-flex justify-content-between align-items-center">Moradia <span class="badge bg-danger">Despesa</span></li>
|
<div class="spinner-border text-primary-dark" role="status"></div>
|
||||||
<li class="list-group-item d-flex justify-content-between align-items-center">Salário <span class="badge bg-success">Receita</span></li>
|
<p class="text-muted mt-2">Carregando categorias...</p>
|
||||||
</ul>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Aba: Segurança e Senha -->
|
<!-- Aba: Segurança e Senha -->
|
||||||
<div v-if="activeTab === 'seguranca'">
|
<div v-if="activeTab === 'seguranca'">
|
||||||
<h2 class="h4 fw-bold text-primary-dark mb-3">Segurança da Conta</h2>
|
<h2 class="h4 fw-bold text-primary-dark mb-3">Segurança da Conta</h2>
|
||||||
<p class="text-muted">Altere sua senha e configure a autenticação de dois fatores.</p>
|
<p class="text-muted">Altere sua senha para manter sua conta segura.</p>
|
||||||
<!-- Placeholder de Formulário -->
|
|
||||||
<form>
|
<form @submit.prevent="changePassword">
|
||||||
<div class="mb-3"><label class="form-label">Nova Senha:</label><input type="password" class="form-control"></div>
|
<div class="mb-3">
|
||||||
<div class="mb-3"><label class="form-label">Confirmar Nova Senha:</label><input type="password" class="form-control"></div>
|
<label class="form-label fw-medium">Senha Atual</label>
|
||||||
<button class="btn btn-warning text-white mt-2">Alterar Senha</button>
|
<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>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Aba: Integrações -->
|
|
||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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-body">
|
||||||
|
<form @submit.prevent="addCategory">
|
||||||
|
<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="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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted } from 'vue';
|
import { ref, onMounted, computed } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
import HeaderApp from '@/components/HeaderApp.vue';
|
import HeaderApp from '@/components/HeaderApp.vue';
|
||||||
|
|
||||||
const activeTab = ref('perfil'); // Aba ativa por padrão
|
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) => {
|
const setActiveTab = (tabName) => {
|
||||||
activeTab.value = tabName;
|
activeTab.value = tabName;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Lógica para marcar o link "Configurações" como ativo no HeaderApp
|
const loadUserProfile = async () => {
|
||||||
onMounted(() => {
|
try {
|
||||||
// Esta lógica é apenas um lembrete. O HeaderApp usa o Vue Router para gerenciar o estado ativo.
|
const user = JSON.parse(localStorage.getItem('user'));
|
||||||
});
|
if (!user) {
|
||||||
|
router.push('/login');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`http://localhost:5000/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(`http://localhost:5000/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('http://localhost:5000/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('http://localhost:5000/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('http://localhost:5000/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>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
/* Estilos Customizados do CtrlCash */
|
/* Estilos Customizados do CtrlCash */
|
||||||
.bg-primary-dark { background-color: #1A3B5E !important; }
|
.bg-primary-dark { background-color: #1A3B5E !important; }
|
||||||
.text-primary-dark { 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 {
|
||||||
.btn-success-feature { background-color: #2ECC71 !important; border-color: #2ECC71 !important; color: white; }
|
background-color: #1A3B5E !important;
|
||||||
.active-feature { background-color: #1A3B5E !important; color: white !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; }
|
.max-w-7xl { max-width: 80rem; }
|
||||||
.mx-auto { margin-left: auto !important; margin-right: auto !important; }
|
.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>
|
</style>
|
||||||
@@ -4,89 +4,100 @@
|
|||||||
|
|
||||||
<div class="login-card p-5 shadow-lg rounded-4 bg-white">
|
<div class="login-card p-5 shadow-lg rounded-4 bg-white">
|
||||||
|
|
||||||
|
|
||||||
<div class="text-center mb-4">
|
<div class="text-center mb-4">
|
||||||
|
|
||||||
<img src="@/assets/CtrlCash-blue.png" alt="CtrlCash Logo" width="150" class="mb-3">
|
<img src="@/assets/CtrlCash-blue.png" alt="CtrlCash Logo" width="150" class="mb-3">
|
||||||
<h2 class="fw-bold text-primary-dark">Login</h2>
|
<h2 class="fw-bold text-primary-dark">Login</h2>
|
||||||
<p class="text-secondary-dark">Insira seus dados para continuar o controle.</p>
|
<p class="text-secondary-dark">Insira seus dados para continuar o controle.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<form @submit.prevent="handleLogin">
|
<form @submit.prevent="handleLogin">
|
||||||
|
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="email" class="form-label fw-medium text-primary-dark">E-mail</label>
|
<label for="email" class="form-label fw-medium text-primary-dark">E-mail</label>
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<span class="input-group-text"><i class="bi bi-envelope-fill"></i></span>
|
<span class="input-group-text"><i class="bi bi-envelope-fill"></i></span>
|
||||||
<input type="email" class="form-control" id="email" v-model="email" required placeholder="seu.email@exemplo.com">
|
<input type="email" class="form-control" id="email" v-model="loginForm.email" required placeholder="seu.email@exemplo.com">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label for="password" class="form-label fw-medium text-primary-dark">Senha</label>
|
<label for="password" class="form-label fw-medium text-primary-dark">Senha</label>
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<span class="input-group-text"><i class="bi bi-lock-fill"></i></span>
|
<span class="input-group-text"><i class="bi bi-lock-fill"></i></span>
|
||||||
<input type="password" class="form-control" id="password" v-model="password" required placeholder="********">
|
<input type="password" class="form-control" id="password" v-model="loginForm.password" required placeholder="********">
|
||||||
</div>
|
</div>
|
||||||
<div class="text-end mt-2">
|
<div class="text-end mt-2">
|
||||||
<a href="/recuperar-senha" class="text-secondary-dark small text-decoration-none hover-link">Esqueceu a senha?</a>
|
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<button type="submit" class="btn btn-primary-feature w-100 fw-bold py-2 shadow-sm" :disabled="loading">
|
||||||
Acessar Minha Conta
|
<span v-if="loading" class="spinner-border spinner-border-sm me-2"></span>
|
||||||
|
{{ loading ? 'Entrando...' : 'Acessar Minha Conta' }}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import HeaderPublic from '@/components/HeaderPublic.vue'
|
|
||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
|
import HeaderPublic from '@/components/HeaderPublic.vue';
|
||||||
|
|
||||||
const email = ref('');
|
|
||||||
const password = ref('');
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const loading = ref(false);
|
||||||
|
const error = ref('');
|
||||||
|
|
||||||
const handleLogin = () => {
|
const loginForm = ref({
|
||||||
|
email: '',
|
||||||
|
password: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleLogin = async () => {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = '';
|
||||||
|
|
||||||
console.log('Tentativa de Login:', email.value);
|
try {
|
||||||
router.push('/dashboard');
|
const response = await fetch('http://localhost:5000/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>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.text-primary-dark { color: #1A3B5E !important; }
|
||||||
|
.text-secondary-dark { color: #6c757d !important; }
|
||||||
|
|
||||||
.text-primary-dark {
|
.login-container { background-color: #F8F9FA; }
|
||||||
color: #1A3B5E !important;
|
.login-card { max-width: 420px; width: 100%; }
|
||||||
}
|
|
||||||
.text-secondary-dark {
|
|
||||||
color: #6c757d !important;
|
|
||||||
}
|
|
||||||
.text-success-feature {
|
|
||||||
color: #2ECC71 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.login-container {
|
|
||||||
background-color: #F8F9FA;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.login-card {
|
|
||||||
max-width: 420px;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.btn-primary-feature {
|
.btn-primary-feature {
|
||||||
background-color: #1A3B5E;
|
background-color: #1A3B5E;
|
||||||
@@ -94,24 +105,13 @@
|
|||||||
color: white;
|
color: white;
|
||||||
transition: background-color 0.2s;
|
transition: background-color 0.2s;
|
||||||
}
|
}
|
||||||
.btn-primary-feature:hover {
|
.btn-primary-feature:hover:not(:disabled) {
|
||||||
background-color: #29517b;
|
background-color: #29517b;
|
||||||
border-color: #29517b;
|
border-color: #29517b;
|
||||||
}
|
}
|
||||||
|
.btn-primary-feature:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
.btn-outline-primary-feature {
|
|
||||||
color: #1A3B5E;
|
|
||||||
border-color: #1A3B5E;
|
|
||||||
background-color: transparent;
|
|
||||||
transition: all 0.2s;
|
|
||||||
margin-top: 15px;
|
|
||||||
}
|
}
|
||||||
.btn-outline-primary-feature:hover {
|
|
||||||
background-color: #1A3B5E;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.form-control:focus {
|
.form-control:focus {
|
||||||
border-color: #1A3B5E;
|
border-color: #1A3B5E;
|
||||||
@@ -123,7 +123,5 @@
|
|||||||
color: #1A3B5E;
|
color: #1A3B5E;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hover-link:hover {
|
.hover-link:hover { text-decoration: underline !important; }
|
||||||
text-decoration: underline !important;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="transacoes-page bg-light min-vh-100">
|
<div class="transacoes-page bg-light min-vh-100">
|
||||||
|
|
||||||
<!-- Componente do Header (Barra de navegação da aplicação) -->
|
<!-- Componente do Header -->
|
||||||
<HeaderApp />
|
<HeaderApp />
|
||||||
|
|
||||||
<div class="container-fluid py-4 max-w-7xl mx-auto px-4">
|
<div class="container-fluid py-4 max-w-7xl mx-auto px-4">
|
||||||
@@ -13,8 +13,7 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-success-feature fw-bold shadow-sm d-flex align-items-center"
|
class="btn btn-success-feature fw-bold shadow-sm d-flex align-items-center"
|
||||||
data-bs-toggle="modal"
|
@click="openAddModal"
|
||||||
data-bs-target="#novaTransacaoModal"
|
|
||||||
>
|
>
|
||||||
<i class="bi bi-plus-circle-fill me-2"></i>
|
<i class="bi bi-plus-circle-fill me-2"></i>
|
||||||
Nova Transação
|
Nova Transação
|
||||||
@@ -28,26 +27,31 @@
|
|||||||
<!-- Filtro por Tipo -->
|
<!-- Filtro por Tipo -->
|
||||||
<div class="col-md-3">
|
<div class="col-md-3">
|
||||||
<label for="filtroTipo" class="form-label small text-muted">Tipo</label>
|
<label for="filtroTipo" class="form-label small text-muted">Tipo</label>
|
||||||
<select id="filtroTipo" class="form-select">
|
<select id="filtroTipo" v-model="filters.type" class="form-select" @change="loadTransactions">
|
||||||
<option selected>Todos</option>
|
<option value="">Todos</option>
|
||||||
<option value="receita">Receitas</option>
|
<option value="income">Receitas</option>
|
||||||
<option value="despesa">Despesas</option>
|
<option value="expense">Despesas</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Filtro por Categoria -->
|
<!-- Filtro por Categoria -->
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<label for="filtroCategoria" class="form-label small text-muted">Categoria</label>
|
<label for="filtroCategoria" class="form-label small text-muted">Categoria</label>
|
||||||
<input type="text" id="filtroCategoria" class="form-control" placeholder="Buscar por categoria...">
|
<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>
|
</div>
|
||||||
|
|
||||||
<!-- Filtro por Período -->
|
<!-- Filtro por Período -->
|
||||||
<div class="col-md-5">
|
<div class="col-md-5">
|
||||||
<label for="filtroPeriodo" class="form-label small text-muted">Período</label>
|
<label for="filtroPeriodo" class="form-label small text-muted">Período</label>
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<input type="date" class="form-control">
|
<input type="date" v-model="filters.startDate" class="form-control" @change="loadTransactions">
|
||||||
<span class="input-group-text">a</span>
|
<span class="input-group-text">a</span>
|
||||||
<input type="date" class="form-control">
|
<input type="date" v-model="filters.endDate" class="form-control" @change="loadTransactions">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -56,7 +60,17 @@
|
|||||||
<!-- Tabela de Transações -->
|
<!-- Tabela de Transações -->
|
||||||
<div class="card shadow-sm border-0">
|
<div class="card shadow-sm border-0">
|
||||||
<div class="card-body p-0">
|
<div class="card-body p-0">
|
||||||
<div class="table-responsive">
|
|
||||||
|
<!-- 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">
|
<table class="table table-hover mb-0">
|
||||||
<thead class="bg-light">
|
<thead class="bg-light">
|
||||||
<tr>
|
<tr>
|
||||||
@@ -69,112 +83,172 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<!-- Exemplo de Receita -->
|
<!-- Transações Dinâmicas -->
|
||||||
<tr>
|
<tr v-for="transaction in transactions" :key="transaction.id">
|
||||||
<td>Salário Mês de Setembro</td>
|
<td>{{ transaction.description }}</td>
|
||||||
<td>Renda Principal</td>
|
<td>
|
||||||
<td class="text-center"><span class="badge bg-success-feature"><i class="bi bi-arrow-up"></i> Receita</span></td>
|
<span class="badge" :style="{
|
||||||
<td class="text-end fw-bold text-success-feature">R$ 4.200,00</td>
|
backgroundColor: getCategoryColor(transaction.category),
|
||||||
<td>28/09/2025</td>
|
color: 'white'
|
||||||
|
}">
|
||||||
|
{{ transaction.category }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
<td class="text-center">
|
<td class="text-center">
|
||||||
<button class="btn btn-sm btn-link text-primary-dark p-0 me-2" title="Editar"><i class="bi bi-pencil-square"></i></button>
|
<span
|
||||||
<button class="btn btn-sm btn-link text-danger p-0" title="Excluir"><i class="bi bi-trash"></i></button>
|
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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<!-- Exemplo de Despesa -->
|
|
||||||
<tr>
|
<!-- Estado Vazio -->
|
||||||
<td>Pagamento do Aluguel</td>
|
<tr v-if="transactions.length === 0 && !loading">
|
||||||
<td>Moradia</td>
|
<td colspan="6" class="text-center py-5 text-muted">
|
||||||
<td class="text-center"><span class="badge bg-danger"><i class="bi bi-arrow-down"></i> Despesa</span></td>
|
<i class="bi bi-receipt fs-1 opacity-50 d-block mb-2"></i>
|
||||||
<td class="text-end fw-bold text-danger">R$ 1.200,00</td>
|
Nenhuma transação encontrada
|
||||||
<td>05/10/2025</td>
|
<br>
|
||||||
<td class="text-center">
|
<small>Clique em "Nova Transação" para adicionar sua primeira transação</small>
|
||||||
<button class="btn btn-sm btn-link text-primary-dark p-0 me-2" title="Editar"><i class="bi bi-pencil-square"></i></button>
|
|
||||||
<button class="btn btn-sm btn-link text-danger p-0" title="Excluir"><i class="bi bi-trash"></i></button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<!-- Mais um Exemplo -->
|
|
||||||
<tr>
|
|
||||||
<td>Compra no Supermercado</td>
|
|
||||||
<td>Alimentação</td>
|
|
||||||
<td class="text-center"><span class="badge bg-danger"><i class="bi bi-arrow-down"></i> Despesa</span></td>
|
|
||||||
<td class="text-end fw-bold text-danger">R$ 250,50</td>
|
|
||||||
<td>15/10/2025</td>
|
|
||||||
<td class="text-center">
|
|
||||||
<button class="btn btn-sm btn-link text-primary-dark p-0 me-2" title="Editar"><i class="bi bi-pencil-square"></i></button>
|
|
||||||
<button class="btn btn-sm btn-link text-danger p-0" title="Excluir"><i class="bi bi-trash"></i></button>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Paginação ou Footer -->
|
|
||||||
<div class="card-footer bg-white text-center">
|
<!-- Footer com Contador -->
|
||||||
<small class="text-muted">Exibindo 1 a 10 de 52 transações.</small>
|
<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>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Modal de Nova Transação (Formulário Flutuante) -->
|
<!-- Modal de Nova Transação -->
|
||||||
<div class="modal fade" id="novaTransacaoModal" tabindex="-1" aria-labelledby="novaTransacaoModalLabel" aria-hidden="true">
|
<div v-if="showAddModal" class="modal-overlay">
|
||||||
<div class="modal-dialog modal-dialog-centered">
|
<div class="modal-container">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<div class="modal-header bg-primary-dark text-white">
|
<div class="modal-header bg-primary-dark text-white">
|
||||||
<h5 class="modal-title fw-bold" id="novaTransacaoModalLabel">Adicionar Nova Transação</h5>
|
<h5 class="modal-title fw-bold">
|
||||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Fechar"></button>
|
Adicionar Nova Transação
|
||||||
|
</h5>
|
||||||
|
<button type="button" class="btn-close btn-close-white" @click="closeModal"></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<form>
|
<form @submit.prevent="saveTransaction">
|
||||||
|
|
||||||
<!-- Tipo de Transação (Receita/Despesa) -->
|
<!-- Tipo de Transação (Receita/Despesa) -->
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label fw-medium text-primary-dark">Tipo</label>
|
<label class="form-label fw-medium text-primary-dark">Tipo</label>
|
||||||
<div class="btn-group w-100" role="group">
|
<div class="btn-group w-100" role="group">
|
||||||
<input type="radio" class="btn-check" name="tipoTransacao" id="tipoReceita" value="receita" checked>
|
<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">Receita</label>
|
<label class="btn btn-outline-success-feature fw-bold" for="tipoReceita"
|
||||||
|
:class="{ 'active': newTransaction.type === 'income' }">
|
||||||
|
Receita
|
||||||
|
</label>
|
||||||
|
|
||||||
<input type="radio" class="btn-check" name="tipoTransacao" id="tipoDespesa" value="despesa">
|
<input type="radio" class="btn-check" v-model="newTransaction.type" id="tipoDespesa" value="expense">
|
||||||
<label class="btn btn-outline-danger fw-bold" for="tipoDespesa">Despesa</label>
|
<label class="btn btn-outline-danger fw-bold" for="tipoDespesa"
|
||||||
|
:class="{ 'active': newTransaction.type === 'expense' }">
|
||||||
|
Despesa
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Descrição -->
|
<!-- Descrição -->
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="descricao" class="form-label fw-medium text-primary-dark">Descrição</label>
|
<label for="descricao" class="form-label fw-medium text-primary-dark">Descrição</label>
|
||||||
<input type="text" class="form-control" id="descricao" placeholder="Ex: Salário, Aluguel, Supermercado">
|
<input
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
id="descricao"
|
||||||
|
v-model="newTransaction.description"
|
||||||
|
placeholder="Ex: Salário, Aluguel, Supermercado"
|
||||||
|
required
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Valor e Data -->
|
<!-- Valor e Data -->
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-md-6 mb-3">
|
<div class="col-md-6 mb-3">
|
||||||
<label for="valor" class="form-label fw-medium text-primary-dark">Valor (R$)</label>
|
<label for="valor" class="form-label fw-medium text-primary-dark">Valor (R$)</label>
|
||||||
<input type="number" step="0.01" class="form-control" id="valor" placeholder="0.00">
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0.01"
|
||||||
|
class="form-control"
|
||||||
|
id="valor"
|
||||||
|
v-model="newTransaction.amount"
|
||||||
|
placeholder="0.00"
|
||||||
|
required
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6 mb-3">
|
<div class="col-md-6 mb-3">
|
||||||
<label for="data" class="form-label fw-medium text-primary-dark">Data</label>
|
<label for="data" class="form-label fw-medium text-primary-dark">Data</label>
|
||||||
<input type="date" class="form-control" id="data">
|
<input
|
||||||
|
type="date"
|
||||||
|
class="form-control"
|
||||||
|
id="data"
|
||||||
|
v-model="newTransaction.date"
|
||||||
|
required
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Categoria -->
|
<!-- Categoria -->
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="categoria" class="form-label fw-medium text-primary-dark">Categoria</label>
|
<label for="categoria" class="form-label fw-medium text-primary-dark">Categoria</label>
|
||||||
<select id="categoria" class="form-select">
|
<select
|
||||||
<option selected>Selecione uma categoria...</option>
|
id="categoria"
|
||||||
<option value="renda">Renda Principal</option>
|
class="form-select"
|
||||||
<option value="moradia">Moradia</option>
|
v-model="newTransaction.category"
|
||||||
<option value="alimentacao">Alimentação</option>
|
required
|
||||||
<option value="transporte">Transporte</option>
|
>
|
||||||
<option value="lazer">Lazer</option>
|
<option value="">Selecione uma categoria...</option>
|
||||||
|
<option
|
||||||
|
v-for="category in filteredCategories"
|
||||||
|
:key="category.id"
|
||||||
|
:value="category.name"
|
||||||
|
>
|
||||||
|
{{ category.name }}
|
||||||
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Mensagens de Erro/Sucesso -->
|
||||||
|
<div v-if="message" class="alert" :class="message.type === 'success' ? 'alert-success' : 'alert-danger'">
|
||||||
|
{{ message.text }}
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancelar</button>
|
<button type="button" class="btn btn-secondary" @click="closeModal">Cancelar</button>
|
||||||
<button type="submit" class="btn btn-primary-dark-feature fw-bold">Salvar Transação</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>
|
</div>
|
||||||
@@ -184,12 +258,241 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
import { ref, onMounted, computed } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
import HeaderApp from '@/components/HeaderApp.vue';
|
import HeaderApp from '@/components/HeaderApp.vue';
|
||||||
// Lógica para controle de filtros e interação com o modal (será adicionada com o Flask)
|
|
||||||
|
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(`http://localhost:5000/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(`http://localhost:5000/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('http://localhost:5000/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(`http://localhost:5000/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>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
/* Definição das cores customizadas para o Bootstrap (Garantia de cores) */
|
/* Definição das cores customizadas para o Bootstrap */
|
||||||
.bg-primary-dark { background-color: #1A3B5E !important; }
|
.bg-primary-dark { background-color: #1A3B5E !important; }
|
||||||
.text-primary-dark { color: #1A3B5E !important; }
|
.text-primary-dark { color: #1A3B5E !important; }
|
||||||
|
|
||||||
@@ -211,18 +514,110 @@
|
|||||||
color: white;
|
color: white;
|
||||||
border-color: #1A3B5E;
|
border-color: #1A3B5E;
|
||||||
}
|
}
|
||||||
.btn-primary-dark-feature:hover {
|
.btn-primary-dark-feature:hover:not(:disabled) {
|
||||||
background-color: #122841;
|
background-color: #122841;
|
||||||
border-color: #122841;
|
border-color: #122841;
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
.btn-primary-dark-feature:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
/* Utilitário para limitar largura (usado no header) */
|
/* Utilitário para limitar largura */
|
||||||
.max-w-7xl {
|
.max-w-7xl {
|
||||||
max-width: 80rem; /* 1280px */
|
max-width: 80rem;
|
||||||
}
|
}
|
||||||
.mx-auto {
|
.mx-auto {
|
||||||
margin-left: auto !important;
|
margin-left: auto !important;
|
||||||
margin-right: 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>
|
</style>
|
||||||
@@ -1,98 +1,114 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="dashboard-page bg-light min-vh-100">
|
<div class="dashboard-page bg-light min-vh-100">
|
||||||
|
|
||||||
<!-- Componente do Header (Barra de navegação da aplicação) -->
|
|
||||||
<HeaderApp />
|
<HeaderApp />
|
||||||
|
|
||||||
<div class="container-fluid py-4 max-w-7xl mx-auto px-4">
|
<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>
|
<h1 class="h3 fw-bold text-primary-dark mb-4">Visão Geral</h1>
|
||||||
|
|
||||||
<!-- Sumário (Cards de Resumo) -->
|
<!-- Sumário (Cards de Resumo) -->
|
||||||
<div class="row g-4 mb-5">
|
<div class="row g-4 mb-5">
|
||||||
|
|
||||||
<!-- Card Saldo Total -->
|
|
||||||
<div class="col-lg-4 col-md-6 col-sm-12">
|
<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 shadow-sm border-0 h-100 p-3">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h5 class="card-title text-muted mb-3">Saldo Total</h5>
|
<h5 class="card-title text-muted mb-3">Saldo Total</h5>
|
||||||
<div class="d-flex justify-content-between align-items-center">
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
<p class="card-text fs-3 fw-bold text-primary-dark">R$ 15.480,23</p>
|
<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>
|
<i class="bi bi-wallet2 fs-2 text-primary-dark opacity-75"></i>
|
||||||
</div>
|
</div>
|
||||||
<small class="text-success-feature fw-medium">+2.5% desde o mês passado</small>
|
<small :class="summary.balance >= 0 ? 'text-success-feature' : 'text-danger'" class="fw-medium">
|
||||||
|
{{ getBalanceTrend() }}
|
||||||
|
</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Card Receitas -->
|
|
||||||
<div class="col-lg-4 col-md-6 col-sm-12">
|
<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 shadow-sm border-0 h-100 p-3">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h5 class="card-title text-muted mb-3">Receitas do Mês</h5>
|
<h5 class="card-title text-muted mb-3">Receitas do Mês</h5>
|
||||||
<div class="d-flex justify-content-between align-items-center">
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
<p class="card-text fs-3 fw-bold text-success-feature">R$ 4.200,00</p>
|
<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>
|
<i class="bi bi-arrow-up-circle-fill fs-2 text-success-feature opacity-75"></i>
|
||||||
</div>
|
</div>
|
||||||
<small class="text-muted">Meta alcançada: 84%</small>
|
<small class="text-muted">{{ getIncomeProgress() }}</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Card Despesas -->
|
|
||||||
<div class="col-lg-4 col-md-12 col-sm-12">
|
<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 shadow-sm border-0 h-100 p-3">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h5 class="card-title text-muted mb-3">Despesas do Mês</h5>
|
<h5 class="card-title text-muted mb-3">Despesas do Mês</h5>
|
||||||
<div class="d-flex justify-content-between align-items-center">
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
<p class="card-text fs-3 fw-bold text-danger">R$ 1.850,50</p>
|
<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>
|
<i class="bi bi-arrow-down-circle-fill fs-2 text-danger opacity-75"></i>
|
||||||
</div>
|
</div>
|
||||||
<small class="text-muted">Limite restante: R$ 649,50</small>
|
<small class="text-muted">{{ getExpenseLimit() }}</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Gráfico Principal (Matplotlib) e Atividade Recente -->
|
<!-- Gráfico e Atividade Recente -->
|
||||||
<div class="row g-4">
|
<div class="row g-4">
|
||||||
|
|
||||||
<!-- Coluna Principal para o Gráfico (Placeholder para Matplotlib) -->
|
|
||||||
<div class="col-lg-8">
|
<div class="col-lg-8">
|
||||||
<div class="card shadow-sm border-0 h-100 p-4">
|
<div class="card shadow-sm border-0 h-100 p-4">
|
||||||
<h4 class="fw-bold text-primary-dark mb-3">Evolução Financeira (Gráfico)</h4>
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
<p class="text-muted">Aqui será renderizado o gráfico gerado pelo Matplotlib no Flask.</p>
|
<h4 class="fw-bold text-primary-dark mb-0">Evolução Financeira</h4>
|
||||||
<!-- Área de 400px de altura para o gráfico -->
|
<div class="btn-group btn-group-sm">
|
||||||
<div class="matplotlib-placeholder bg-light-subtle rounded-3 border border-dashed p-5 text-center d-flex align-items-center justify-content-center" style="min-height: 400px; height: 100%;">
|
<button @click="loadChartData('monthly')" class="btn btn-outline-primary" :class="{ 'active': chartType === 'monthly' }">
|
||||||
<i class="bi bi-bar-chart-fill fs-1 text-primary-dark opacity-25"></i>
|
Mensal
|
||||||
<p class="ms-3 text-primary-dark opacity-50">Carregando visualização de dados...</p>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Coluna de Atividade Recente -->
|
|
||||||
<div class="col-lg-4">
|
<div class="col-lg-4">
|
||||||
<div class="card shadow-sm border-0 h-100 p-4">
|
<div class="card shadow-sm border-0 h-100 p-4">
|
||||||
<h4 class="fw-bold text-primary-dark mb-3">Atividade Recente</h4>
|
<h4 class="fw-bold text-primary-dark mb-3">Atividade Recente</h4>
|
||||||
<ul class="list-group list-group-flush">
|
|
||||||
<li class="list-group-item d-flex justify-content-between align-items-center">
|
<div v-if="loadingTransactions" class="text-center py-4">
|
||||||
Pagamento de Aluguel
|
<div class="spinner-border text-primary-dark" role="status"></div>
|
||||||
<span class="badge bg-danger">-R$ 1.200,00</span>
|
</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>
|
||||||
<li class="list-group-item d-flex justify-content-between align-items-center">
|
|
||||||
Salário - Empresa Y
|
<li v-if="summary.recent_transactions && summary.recent_transactions.length === 0" class="list-group-item text-center text-muted py-4">
|
||||||
<span class="badge bg-success-feature">+R$ 4.200,00</span>
|
<i class="bi bi-receipt fs-4 opacity-50 d-block mb-2"></i>
|
||||||
|
Nenhuma transação recente
|
||||||
</li>
|
</li>
|
||||||
<li class="list-group-item d-flex justify-content-between align-items-center">
|
|
||||||
Compra Mercado
|
<li class="list-group-item text-center pt-3 border-0">
|
||||||
<span class="badge bg-danger">-R$ 250,50</span>
|
<router-link to="/transacoes" class="btn btn-link btn-sm text-primary-dark fw-medium">
|
||||||
</li>
|
Ver todas as transações
|
||||||
<li class="list-group-item d-flex justify-content-between align-items-center">
|
</router-link>
|
||||||
Recebimento de Freelance
|
|
||||||
<span class="badge bg-success-feature">+R$ 500,00</span>
|
|
||||||
</li>
|
|
||||||
<li class="list-group-item text-center">
|
|
||||||
<router-link to="/transacoes" class="btn btn-link btn-sm text-primary-dark fw-medium">Ver todas as transações</router-link>
|
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
@@ -103,8 +119,103 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
import HeaderApp from '@/components/HeaderApp.vue';
|
import HeaderApp from '@/components/HeaderApp.vue';
|
||||||
// Importações e lógica do dashboard (serão adicionadas ao integrar com Flask)
|
|
||||||
|
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(`http://localhost:5000/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(`http://localhost:5000/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>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -113,17 +224,16 @@
|
|||||||
.bg-success-feature { background-color: #2ECC71 !important; }
|
.bg-success-feature { background-color: #2ECC71 !important; }
|
||||||
.text-success-feature { color: #2ECC71 !important; }
|
.text-success-feature { color: #2ECC71 !important; }
|
||||||
|
|
||||||
/* Estilos de Card de Destaque */
|
.max-w-7xl { max-width: 80rem; }
|
||||||
.card-body {
|
.mx-auto { margin-left: auto !important; margin-right: auto !important; }
|
||||||
padding: 1.5rem;
|
|
||||||
|
.btn-outline-primary.active {
|
||||||
|
background-color: #1A3B5E;
|
||||||
|
border-color: #1A3B5E;
|
||||||
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Utilitário para limitar largura (usado no header) */
|
.transaction-info { flex: 1; text-align: left; }
|
||||||
.max-w-7xl {
|
.chart-container { background-color: white; border: 1px solid #e9ecef; }
|
||||||
max-width: 80rem; /* 1280px */
|
.border-dashed { border-style: dashed !important; }
|
||||||
}
|
|
||||||
.mx-auto {
|
|
||||||
margin-left: auto !important;
|
|
||||||
margin-right: auto !important;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
Reference in New Issue
Block a user