Files
carvalho-finances/apps/web/src/views/AccountsView.vue
T
Mlcavalho1andClaude Sonnet 4.6 ee6cd9f37e feat(#35): rendimento CDI automático em contas de investimento
- Migration 010: colunas yield_type e last_yield_date em accounts + source='yield' nas transações
- CDIYieldService: busca taxas BCB (serie 12) e cria transação source=yield com rendimento acumulado
- Weekends/feriados sem taxa usam última taxa disponível (AC4)
- GET /api/accounts dispara cálculo CDI on-demand antes de retornar saldos
- AccountsView: seletor yield_type (none/cdi/variable) + badge CDI/VAR na listagem
- Transações source=yield aparecem no histórico com descrição 'Rendimento CDI — {período}'

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-27 15:06:05 -03:00

269 lines
8.0 KiB
Vue

<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useAccountsStore, type AccountInput } from '@/stores/accounts'
import NeonPanel from '@/components/NeonPanel.vue'
const store = useAccountsStore()
onMounted(() => store.fetchAll())
const typeLabels: Record<string, string> = {
checking: 'Conta Corrente',
savings: 'Poupança',
investment: 'Investimento',
credit: 'Cartão de Crédito',
}
const blank = (): AccountInput => ({ name: '', type: 'checking', initial_balance: 0, yield_type: 'none' })
const form = ref(blank())
const editId = ref<number | null>(null)
const initialRaw = ref('')
const formError = ref<string | null>(null)
function parseAmount(s: string) {
return parseFloat(s.replace(/\./g, '').replace(',', '.')) || 0
}
function startEdit(id: number) {
const a = store.accounts.find((x) => x.id === id)
if (!a) return
editId.value = id
form.value = { name: a.name, type: a.type, initial_balance: a.initial_balance, yield_type: a.yield_type ?? 'none' }
initialRaw.value = a.initial_balance.toLocaleString('pt-BR', { minimumFractionDigits: 2 })
formError.value = null
}
function cancelEdit() {
editId.value = null
form.value = blank()
initialRaw.value = ''
formError.value = null
}
async function submit() {
formError.value = null
form.value.initial_balance = parseAmount(initialRaw.value)
try {
if (editId.value !== null) {
await store.update(editId.value, form.value)
cancelEdit()
} else {
await store.create(form.value)
form.value = blank()
initialRaw.value = ''
}
} catch (e: any) {
formError.value = e.message
}
}
async function remove(id: number, name: string) {
if (!confirm(`Excluir conta "${name}"?`)) return
await store.remove(id)
}
function fmt(v: number) {
return v.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' })
}
function totalBalance() {
return store.accounts.reduce((s, a) => s + a.balance, 0)
}
</script>
<template>
<div class="fc-view">
<span class="fc-pixel fc-view__title">:: CONTAS BANCÁRIAS</span>
<!-- Form -->
<NeonPanel :title="editId !== null ? 'EDITAR CONTA' : 'NOVA CONTA'">
<form class="fc-acc-form" @submit.prevent="submit">
<div class="fc-acc-form__row">
<input v-model="form.name" placeholder="Ex: Nubank Corrente" required class="fc-input fc-acc-form__name" />
<select v-model="form.type" class="fc-select fc-acc-form__type">
<option value="checking">Conta Corrente</option>
<option value="savings">Poupança</option>
<option value="investment">Investimento</option>
<option value="credit">Cartão de Crédito</option>
</select>
<select v-model="form.yield_type" class="fc-select fc-acc-form__yield">
<option value="none">Sem rendimento</option>
<option value="cdi">CDI automático</option>
<option value="variable">Renda variável</option>
</select>
<input
v-model="initialRaw"
placeholder="Saldo inicial (ex: 1.500,00)"
class="fc-input fc-acc-form__balance"
/>
</div>
<p v-if="formError" class="fc-acc-form__error fc-mono">{{ formError }}</p>
<div class="fc-acc-form__actions">
<button type="submit" class="fc-btn fc-btn--primary">{{ editId !== null ? 'SALVAR' : 'ADICIONAR' }}</button>
<button v-if="editId !== null" type="button" class="fc-btn fc-btn--ghost" @click="cancelEdit">CANCELAR</button>
</div>
</form>
</NeonPanel>
<!-- Summary -->
<NeonPanel v-if="store.accounts.length > 0" title="PATRIMÔNIO TOTAL">
<div class="fc-acc-total">
<span class="fc-mono fc-acc-total__value" :class="totalBalance() >= 0 ? 'fc-text-green' : 'fc-text-red'">
{{ fmt(totalBalance()) }}
</span>
<span class="fc-mono fc-acc-total__sub">{{ store.accounts.length }} conta{{ store.accounts.length !== 1 ? 's' : '' }}</span>
</div>
</NeonPanel>
<!-- Account list -->
<NeonPanel title="CONTAS">
<p v-if="store.loading" class="fc-acc-loading fc-mono">carregando...</p>
<ul v-else class="fc-acc-list">
<li
v-for="a in store.accounts"
:key="a.id"
class="fc-acc-item"
:class="{ 'fc-acc-item--editing': editId === a.id }"
>
<div class="fc-acc-item__info">
<div class="fc-acc-item__name-row">
<span class="fc-body fc-acc-item__name">{{ a.name }}</span>
<span v-if="a.yield_type === 'cdi'" class="fc-acc-badge fc-acc-badge--cdi fc-pixel">CDI</span>
<span v-else-if="a.yield_type === 'variable'" class="fc-acc-badge fc-acc-badge--var fc-pixel">VAR</span>
</div>
<span class="fc-mono fc-acc-item__meta">
{{ typeLabels[a.type] }} · inicial {{ fmt(a.initial_balance) }}
</span>
</div>
<div class="fc-acc-item__right">
<span class="fc-mono fc-acc-item__balance" :class="a.balance >= 0 ? 'fc-text-green' : 'fc-text-red'">
{{ fmt(a.balance) }}
</span>
<div class="fc-acc-item__actions">
<button class="fc-btn fc-btn--sm fc-btn--ghost" @click="startEdit(a.id)">EDITAR</button>
<button class="fc-btn fc-btn--sm fc-btn--danger" @click="remove(a.id, a.name)"></button>
</div>
</div>
</li>
<li v-if="store.accounts.length === 0" class="fc-acc-empty fc-mono"> nenhuma conta cadastrada </li>
</ul>
</NeonPanel>
</div>
</template>
<style scoped>
.fc-view {
max-width: 640px;
margin: 0 auto;
padding: var(--fc-space-4);
display: flex;
flex-direction: column;
gap: var(--fc-space-4);
padding-bottom: 96px;
}
.fc-view__title {
font-size: 11px;
color: var(--fc-accent-2);
}
.fc-acc-form__row {
display: flex;
gap: var(--fc-space-2);
flex-wrap: wrap;
align-items: flex-end;
}
.fc-acc-form__name { flex: 1; min-width: 160px; }
.fc-acc-form__type { width: 150px; }
.fc-acc-form__yield { width: 160px; }
.fc-acc-form__balance { width: 150px; }
.fc-acc-form__error {
color: var(--fc-red);
font-size: 11px;
margin-top: var(--fc-space-2);
}
.fc-acc-form__actions {
display: flex;
gap: var(--fc-space-2);
margin-top: var(--fc-space-3);
}
/* Total */
.fc-acc-total {
display: flex;
align-items: baseline;
gap: var(--fc-space-3);
}
.fc-acc-total__value {
font-size: 24px;
font-weight: 700;
}
.fc-acc-total__sub {
font-size: 11px;
color: var(--fc-text-dim);
}
/* List */
.fc-acc-loading, .fc-acc-empty {
font-size: 11px;
color: var(--fc-text-dim);
text-align: center;
padding: var(--fc-space-4) 0;
}
.fc-acc-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: var(--fc-space-2);
}
.fc-acc-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--fc-space-3);
padding: 12px var(--fc-space-3);
border: 1px solid var(--fc-panel-edge);
border-radius: var(--fc-radius);
flex-wrap: wrap;
transition: border-color .15s;
}
.fc-acc-item--editing {
border-color: var(--fc-accent-3);
box-shadow: 0 0 8px rgba(168,85,247,.3);
}
.fc-acc-item__info {
display: flex;
flex-direction: column;
gap: 4px;
}
.fc-acc-item__name-row { display: flex; align-items: center; gap: 8px; }
.fc-acc-item__name { font-size: 14px; font-weight: 500; }
.fc-acc-item__meta { font-size: 11px; color: var(--fc-text-dim); }
.fc-acc-badge {
font-size: 7px; padding: 2px 5px; border-radius: 2px; letter-spacing: .05em;
}
.fc-acc-badge--cdi { background: rgba(34,197,94,.2); color: var(--fc-green); border: 1px solid var(--fc-green); }
.fc-acc-badge--var { background: rgba(251,191,36,.2); color: var(--fc-gold); border: 1px solid var(--fc-gold); }
.fc-acc-item__right {
display: flex;
align-items: center;
gap: var(--fc-space-3);
}
.fc-acc-item__balance { font-size: 16px; font-weight: 700; }
.fc-acc-item__actions { display: flex; gap: var(--fc-space-1); }
</style>