- Adiciona campo cdi_percentage (default 100%) na tabela accounts - Cálculo CDI aplica proporção: balance × (compound-1) × (pct/100) - Frontend exibe input de % quando yield_type=cdi e badge mostra valor - Corrige mocks de teste desatualizados (DeleteByMonth, isTax) - Corrige ConfirmIncome para rejeitar tipo expense (ErrRecurringNotIncome) Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
331 lines
11 KiB
Vue
331 lines
11 KiB
Vue
<script setup lang="ts">
|
|
import { ref, onMounted } from 'vue'
|
|
import { useAccountsStore, type AccountInput } from '@/stores/accounts'
|
|
import NeonPanel from '@/components/NeonPanel.vue'
|
|
import CurrencyInput from '@/components/CurrencyInput.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', cdi_percentage: 100, closing_day: null, due_day: null })
|
|
const form = ref(blank())
|
|
const editId = ref<number | null>(null)
|
|
const formError = ref<string | null>(null)
|
|
|
|
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',
|
|
cdi_percentage: a.cdi_percentage ?? 100,
|
|
closing_day: a.closing_day ?? null,
|
|
due_day: a.due_day ?? null,
|
|
}
|
|
formError.value = null
|
|
}
|
|
|
|
async function payBill(billId: number) {
|
|
if (!confirm('Marcar fatura como PAGA?')) return
|
|
await store.payBill(billId)
|
|
}
|
|
|
|
function cancelEdit() {
|
|
editId.value = null
|
|
form.value = blank()
|
|
formError.value = null
|
|
}
|
|
|
|
async function submit() {
|
|
formError.value = null
|
|
try {
|
|
if (editId.value !== null) {
|
|
await store.update(editId.value, form.value)
|
|
cancelEdit()
|
|
} else {
|
|
await store.create(form.value)
|
|
form.value = blank()
|
|
}
|
|
} 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>
|
|
<div v-if="form.yield_type === 'cdi'" class="fc-label fc-acc-form__day-wrap">
|
|
% do CDI
|
|
<input
|
|
type="number"
|
|
v-model.number="form.cdi_percentage"
|
|
min="1"
|
|
max="200"
|
|
step="0.5"
|
|
class="fc-input fc-acc-form__day"
|
|
/>
|
|
</div>
|
|
<template v-if="form.type === 'credit'">
|
|
<div class="fc-label fc-acc-form__day-wrap">
|
|
Fechamento
|
|
<input type="number" v-model.number="form.closing_day" min="1" max="28" class="fc-input fc-acc-form__day" />
|
|
</div>
|
|
<div class="fc-label fc-acc-form__day-wrap">
|
|
Vencimento
|
|
<input type="number" v-model.number="form.due_day" min="1" max="28" class="fc-input fc-acc-form__day" />
|
|
</div>
|
|
</template>
|
|
<CurrencyInput
|
|
v-model="form.initial_balance"
|
|
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">
|
|
{{ a.cdi_percentage != null && a.cdi_percentage !== 100 ? a.cdi_percentage + '% CDI' : '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>
|
|
|
|
<!-- Current bills for credit accounts -->
|
|
<template v-for="a in store.accounts.filter(x => x.type === 'credit' && x.current_bill)" :key="'bill-' + a.id">
|
|
<NeonPanel :title="`FATURA · ${a.name}`" :variant="a.current_bill!.paid ? undefined : 'danger'">
|
|
<div class="fc-bill">
|
|
<div class="fc-bill__row">
|
|
<span class="fc-mono fc-bill__label">Período</span>
|
|
<span class="fc-mono fc-bill__val">{{ a.current_bill!.period_start }} → {{ a.current_bill!.period_end }}</span>
|
|
</div>
|
|
<div class="fc-bill__row">
|
|
<span class="fc-mono fc-bill__label">Vencimento</span>
|
|
<span class="fc-mono fc-bill__val fc-blink" style="color:var(--fc-red)">{{ a.current_bill!.due_date }}</span>
|
|
</div>
|
|
<div class="fc-bill__row">
|
|
<span class="fc-pixel fc-bill__label" style="font-size:8px">TOTAL FATURA</span>
|
|
<span class="fc-mono fc-bill__total" style="color:var(--fc-red); font-size:20px; font-weight:700">
|
|
{{ fmt(a.current_bill!.total) }}
|
|
</span>
|
|
</div>
|
|
<div v-if="!a.current_bill!.paid" class="fc-bill__actions">
|
|
<button class="fc-btn fc-btn--primary" @click="payBill(a.current_bill!.id)">MARCAR PAGA</button>
|
|
</div>
|
|
<div v-else class="fc-bill__paid fc-pixel">FATURA PAGA ✓</div>
|
|
</div>
|
|
</NeonPanel>
|
|
</template>
|
|
</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-form__day-wrap { width: 90px; flex-shrink: 0; }
|
|
.fc-acc-form__day { width: 100%; }
|
|
|
|
/* Credit bill widget */
|
|
.fc-bill { display: flex; flex-direction: column; gap: var(--fc-space-3); }
|
|
.fc-bill__row { display: flex; justify-content: space-between; align-items: baseline; gap: var(--fc-space-2); }
|
|
.fc-bill__label { font-size: 10px; color: var(--fc-text-dim); }
|
|
.fc-bill__val { font-size: 12px; }
|
|
.fc-bill__actions { margin-top: var(--fc-space-2); }
|
|
.fc-bill__paid { font-size: 8px; color: var(--fc-green); margin-top: var(--fc-space-2); }
|
|
|
|
.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>
|