feat(#36): receitas recorrentes com confirmação de salário até dia 5

- Migration 009: coluna `type` em recurring_expenses + tabela recurring_late
- API Go: tipo income/expense em CRUD; endpoints POST /confirm e /late
- Dashboard: campo pending_income_recurrings na resposta
- Frontend: RecurringView com seções RECEITAS e DESPESAS separadas
- HomeView: widget de confirmação (dia 1-5) e badge SALÁRIO PENDENTE (pós dia 5)
- Testes unitários: 4 novos casos (income covered, late, confirm, reject expense)

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
This commit is contained in:
2026-05-27 15:00:28 -03:00
co-authored by Claude Sonnet 4.6
parent 1931271b76
commit 79f4a62a1b
16 changed files with 725 additions and 77 deletions
+1 -1
View File
@@ -49,7 +49,7 @@ const navItems = [
{ to: '/importar', label: 'IMPORT' },
{ to: '/contas', label: 'CONTAS' },
{ to: '/personagem', label: 'PERS.' },
{ to: '/configuracoes', label: 'CFG' },
{ to: '/recorrencias', label: 'REC.' },
]
</script>
+6 -2
View File
@@ -41,10 +41,14 @@ const router = createRouter({
name: 'character',
component: () => import('../views/CharacterView.vue'),
},
{
path: '/recorrencias',
name: 'recurring',
component: () => import('../views/RecurringView.vue'),
},
{
path: '/configuracoes',
name: 'settings',
component: () => import('../views/SettingsView.vue'),
redirect: '/recorrencias',
},
],
})
+9
View File
@@ -25,6 +25,14 @@ export interface RecentTransaction {
type: 'income' | 'expense'
}
export interface PendingIncome {
id: number
name: string
expected_amount: number
day_of_month: number
late: boolean
}
export interface DashboardData {
month: string
total_income: number
@@ -35,6 +43,7 @@ export interface DashboardData {
monthly_evolution: MonthEvolution[]
recent_transactions: RecentTransaction[]
pending_recurring: number
pending_income_recurrings: PendingIncome[]
}
export const useDashboardStore = defineStore('dashboard', () => {
+28 -2
View File
@@ -1,7 +1,6 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { api } from '@/services/api'
import type { Category } from './categories'
export interface RecurringExpense {
id: number
@@ -9,6 +8,7 @@ export interface RecurringExpense {
expected_amount: number
day_of_month: number
category_id: number | null
type: 'income' | 'expense'
active: boolean
created_at: string
updated_at: string
@@ -17,6 +17,7 @@ export interface RecurringExpense {
export interface RecurringStatus extends RecurringExpense {
covered: boolean
ignored: boolean
late: boolean
reason?: string
}
@@ -74,8 +75,33 @@ export const useRecurringStore = defineStore('recurring', () => {
await fetchMonthlyStatus(month)
}
async function confirmIncome(id: number, month: string) {
await api.post(`/recurring/${id}/confirm`, { month })
await fetchMonthlyStatus(month)
}
async function markLate(id: number, month: string) {
await api.post(`/recurring/${id}/late`, { month })
await fetchMonthlyStatus(month)
}
const pendingCount = (month: string) =>
monthlyStatus.value.filter((s) => !s.covered).length
return { items, monthlyStatus, loading, error, fetchAll, fetchMonthlyStatus, create, update, remove, ignore, unignore, pendingCount }
return {
items,
monthlyStatus,
loading,
error,
fetchAll,
fetchMonthlyStatus,
create,
update,
remove,
ignore,
unignore,
confirmIncome,
markLate,
pendingCount,
}
})
+69 -1
View File
@@ -2,6 +2,7 @@
import { ref, computed, onMounted } from 'vue'
import { useDashboardStore } from '@/stores/dashboard'
import { useGameStore } from '@/stores/game'
import { useRecurringStore } from '@/stores/recurring'
import NeonPanel from '@/components/NeonPanel.vue'
import XPBar from '@/components/XPBar.vue'
import CharacterSprite from '@/components/CharacterSprite.vue'
@@ -9,6 +10,7 @@ import MonthSwitcher from '@/components/MonthSwitcher.vue'
const dash = useDashboardStore()
const game = useGameStore()
const recurring = useRecurringStore()
const currentMonth = ref(new Date().toISOString().slice(0, 7))
onMounted(() => dash.fetch(currentMonth.value))
@@ -51,6 +53,22 @@ const xpPct = computed(() => {
})
const savingsPct = computed(() => dash.data?.savings_pct ?? 0)
const savingsOk = computed(() => savingsPct.value >= 40)
const today = new Date()
const isCurrentMonth = computed(() => currentMonth.value === today.toISOString().slice(0, 7))
const dayOfMonth = today.getDate()
const pendingIncome = computed(() => dash.data?.pending_income_recurrings ?? [])
const showIncomeWidget = computed(() => isCurrentMonth.value && pendingIncome.value.length > 0)
const showIncomeConfirmButtons = computed(() => dayOfMonth <= 5)
async function confirmIncome(id: number) {
await recurring.confirmIncome(id, currentMonth.value)
await dash.fetch(currentMonth.value)
}
async function markLate(id: number) {
await recurring.markLate(id, currentMonth.value)
await dash.fetch(currentMonth.value)
}
</script>
<template>
@@ -64,7 +82,7 @@ const savingsOk = computed(() => savingsPct.value >= 40)
<div v-if="dash.loading" class="dash-loading fc-pixel">CARREGANDO...</div>
<template v-else-if="dash.data">
<!-- Alert banner -->
<!-- Alert banner despesas recorrentes ausentes -->
<div v-if="dash.data.pending_recurring > 0" class="dash-alert">
<span class="fc-blink" style="color:var(--fc-red)"></span>
<span class="fc-pixel" style="font-size:8px">
@@ -72,6 +90,37 @@ const savingsOk = computed(() => savingsPct.value >= 40)
</span>
</div>
<!-- Income confirmation widget (day 15 of current month) -->
<template v-if="showIncomeWidget">
<NeonPanel v-if="showIncomeConfirmButtons" title="CONFIRMAR RECEITAS" :variant="'income'" class="dash-income-panel">
<div class="income-list">
<div v-for="item in pendingIncome" :key="item.id" class="income-item">
<div class="income-item__info">
<span class="fc-body income-item__name">{{ item.name }}</span>
<span class="fc-mono income-item__amount" style="color:var(--fc-green)">
{{ fmt(item.expected_amount) }}
</span>
</div>
<div v-if="item.late" class="income-item__actions">
<span class="fc-chip income-chip--late fc-pixel">ATRASOU</span>
</div>
<div v-else class="income-item__actions">
<button class="fc-btn fc-btn--sm fc-btn--primary" @click="confirmIncome(item.id)">CONFIRMAR</button>
<button class="fc-btn fc-btn--sm fc-btn--ghost" @click="markLate(item.id)">ATRASOU</button>
</div>
</div>
</div>
</NeonPanel>
<!-- After day 5: salary pending badge -->
<div v-else class="dash-alert dash-alert--salary">
<span class="fc-blink" style="color:var(--fc-gold)"></span>
<span class="fc-pixel" style="font-size:8px">
SALÁRIO PENDENTE {{ pendingIncome.map(i => i.name).join(', ') }}
</span>
</div>
</template>
<div class="dash-grid">
<!-- Left column -->
<div class="dash-col-main">
@@ -289,6 +338,25 @@ const savingsOk = computed(() => savingsPct.value >= 40)
.empty { font-size: 8px; color: var(--fc-text-dim); padding: 8px 0; }
/* Income widget */
.dash-income-panel { margin-bottom: 0; }
.dash-alert--salary { background: rgba(255,180,0,.08); border-color: var(--fc-gold); }
.income-list { display: flex; flex-direction: column; gap: 10px; }
.income-item {
display: flex; align-items: center; justify-content: space-between;
gap: var(--fc-space-3); padding: 10px var(--fc-space-3);
border: 1px solid var(--fc-panel-edge); border-radius: var(--fc-radius);
flex-wrap: wrap;
}
.income-item__info { display: flex; align-items: center; gap: var(--fc-space-3); }
.income-item__name { font-size: 14px; font-weight: 500; }
.income-item__amount { font-size: 13px; font-weight: 700; }
.income-item__actions { display: flex; gap: var(--fc-space-1); align-items: center; }
.income-chip--late {
font-size: 7px; padding: 3px 6px; border-radius: 2px;
background: var(--fc-accent); color: var(--fc-bg);
}
@media (max-width: 767px) {
.dash-page { padding: 14px 12px 80px; }
.tx-row { grid-template-columns: 70px 1fr 80px; }
+293
View File
@@ -0,0 +1,293 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRecurringStore } from '@/stores/recurring'
import { useCategoriesStore } from '@/stores/categories'
import NeonPanel from '@/components/NeonPanel.vue'
const store = useRecurringStore()
const catStore = useCategoriesStore()
const currentMonth = ref(new Date().toISOString().slice(0, 7))
onMounted(async () => {
await Promise.all([store.fetchAll(), catStore.fetchAll()])
await store.fetchMonthlyStatus(currentMonth.value)
})
const blank = (): Omit<typeof store.items[0], 'id' | 'active' | 'created_at' | 'updated_at'> => ({
name: '',
expected_amount: 0,
day_of_month: 1,
category_id: null,
type: 'expense',
})
const form = ref(blank())
const editId = ref<number | null>(null)
const amountRaw = ref('')
const formError = ref<string | null>(null)
function parseAmount(s: string) {
return parseFloat(s.replace(/\./g, '').replace(',', '.')) || 0
}
function startEdit(id: number) {
const item = store.items.find((x) => x.id === id)
if (!item) return
editId.value = id
form.value = { name: item.name, expected_amount: item.expected_amount, day_of_month: item.day_of_month, category_id: item.category_id, type: item.type }
amountRaw.value = item.expected_amount.toLocaleString('pt-BR', { minimumFractionDigits: 2 })
formError.value = null
}
function cancelEdit() {
editId.value = null
form.value = blank()
amountRaw.value = ''
formError.value = null
}
async function submit() {
formError.value = null
form.value.expected_amount = parseAmount(amountRaw.value)
try {
if (editId.value !== null) {
await store.update(editId.value, form.value)
cancelEdit()
} else {
await store.create(form.value)
form.value = blank()
amountRaw.value = ''
}
await store.fetchMonthlyStatus(currentMonth.value)
} catch (e: any) {
formError.value = e.message
}
}
async function remove(id: number, name: string) {
if (!confirm(`Excluir recorrência "${name}"?`)) return
await store.remove(id)
}
async function confirmIncome(id: number) {
await store.confirmIncome(id, currentMonth.value)
}
async function markLate(id: number) {
await store.markLate(id, currentMonth.value)
}
function catName(id: number | null) {
if (!id) return '—'
return catStore.categories.find((c) => c.id === id)?.name ?? '—'
}
function fmt(v: number) {
return v.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' })
}
const incomeItems = computed(() => store.items.filter((x) => x.type === 'income'))
const expenseItems = computed(() => store.items.filter((x) => x.type === 'expense'))
function getStatus(id: number) {
return store.monthlyStatus.find((s) => s.id === id)
}
</script>
<template>
<div class="fc-view">
<span class="fc-pixel fc-view__title">:: RECORRÊNCIAS</span>
<!-- Form -->
<NeonPanel :title="editId !== null ? 'EDITAR RECORRÊNCIA' : 'NOVA RECORRÊNCIA'">
<form class="rec-form" @submit.prevent="submit">
<div class="rec-form__row">
<input v-model="form.name" placeholder="Nome (ex: Salário, Netflix)" required class="fc-input rec-form__name" />
<input v-model="amountRaw" placeholder="3.200,00" required class="fc-input rec-form__amount" />
<div class="fc-label rec-form__day-wrap">
Dia
<input type="number" v-model.number="form.day_of_month" min="1" max="31" class="fc-input rec-form__day" />
</div>
<select v-model="form.category_id" class="fc-select rec-form__cat">
<option :value="null">Sem categoria</option>
<option v-for="c in catStore.categories" :key="c.id" :value="c.id">{{ c.name }}</option>
</select>
<select v-model="form.type" class="fc-select rec-form__type">
<option value="expense">DESPESA</option>
<option value="income">RECEITA</option>
</select>
</div>
<p v-if="formError" class="rec-form__error fc-mono">{{ formError }}</p>
<div class="rec-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>
<!-- RECEITAS -->
<NeonPanel title="RECEITAS">
<p v-if="store.loading" class="rec-empty fc-mono">carregando...</p>
<ul v-else class="rec-list">
<li v-for="item in incomeItems" :key="item.id" class="rec-item" :class="{ 'rec-item--editing': editId === item.id }">
<div class="rec-item__info">
<span class="fc-body rec-item__name">{{ item.name }}</span>
<span class="fc-mono rec-item__meta">
Todo dia {{ item.day_of_month }} · {{ fmt(item.expected_amount) }} · {{ catName(item.category_id) }}
</span>
</div>
<div class="rec-item__right">
<!-- Monthly status chip -->
<template v-if="getStatus(item.id)">
<span v-if="getStatus(item.id)!.covered" class="fc-chip chip--ok fc-pixel">CONFIRMADO</span>
<template v-else-if="getStatus(item.id)!.late">
<span class="fc-chip chip--late fc-pixel">ATRASOU</span>
</template>
<template v-else>
<button class="fc-btn fc-btn--sm fc-btn--primary" @click="confirmIncome(item.id)">CONFIRMAR</button>
<button class="fc-btn fc-btn--sm fc-btn--ghost" @click="markLate(item.id)">ATRASOU</button>
</template>
</template>
<div class="rec-item__actions">
<button class="fc-btn fc-btn--sm fc-btn--ghost" @click="startEdit(item.id)">EDITAR</button>
<button class="fc-btn fc-btn--sm fc-btn--danger" @click="remove(item.id, item.name)"></button>
</div>
</div>
</li>
<li v-if="incomeItems.length === 0" class="rec-empty fc-mono"> nenhuma receita recorrente cadastrada </li>
</ul>
</NeonPanel>
<!-- DESPESAS -->
<NeonPanel title="DESPESAS">
<p v-if="store.loading" class="rec-empty fc-mono">carregando...</p>
<ul v-else class="rec-list">
<li v-for="item in expenseItems" :key="item.id" class="rec-item" :class="{ 'rec-item--editing': editId === item.id }">
<div class="rec-item__info">
<span class="fc-body rec-item__name">{{ item.name }}</span>
<span class="fc-mono rec-item__meta">
Todo dia {{ item.day_of_month }} · {{ fmt(item.expected_amount) }} · {{ catName(item.category_id) }}
</span>
</div>
<div class="rec-item__right">
<template v-if="getStatus(item.id)">
<span v-if="getStatus(item.id)!.covered" class="fc-chip chip--ok fc-pixel">OK</span>
<span v-else class="fc-chip chip--pending fc-pixel">PENDENTE</span>
</template>
<div class="rec-item__actions">
<button class="fc-btn fc-btn--sm fc-btn--ghost" @click="startEdit(item.id)">EDITAR</button>
<button class="fc-btn fc-btn--sm fc-btn--danger" @click="remove(item.id, item.name)"></button>
</div>
</div>
</li>
<li v-if="expenseItems.length === 0" class="rec-empty fc-mono"> nenhuma despesa recorrente 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);
}
.rec-form__row {
display: flex;
gap: var(--fc-space-2);
flex-wrap: wrap;
align-items: flex-end;
}
.rec-form__name { flex: 1; min-width: 150px; }
.rec-form__amount { width: 110px; }
.rec-form__day-wrap { width: 70px; flex-shrink: 0; }
.rec-form__day { width: 100%; }
.rec-form__cat { width: 150px; }
.rec-form__type { width: 110px; }
.rec-form__error {
color: var(--fc-red);
font-size: 11px;
margin-top: var(--fc-space-2);
}
.rec-form__actions {
display: flex;
gap: var(--fc-space-2);
margin-top: var(--fc-space-3);
}
.rec-empty {
font-size: 11px;
color: var(--fc-text-dim);
text-align: center;
padding: var(--fc-space-4) 0;
}
.rec-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: var(--fc-space-2);
}
.rec-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;
}
.rec-item--editing {
border-color: var(--fc-accent-3);
box-shadow: 0 0 8px rgba(168,85,247,.3);
}
.rec-item__info {
display: flex;
flex-direction: column;
gap: 4px;
flex: 1;
}
.rec-item__name { font-size: 14px; font-weight: 500; }
.rec-item__meta { font-size: 11px; color: var(--fc-text-dim); }
.rec-item__right {
display: flex;
align-items: center;
gap: var(--fc-space-2);
flex-wrap: wrap;
}
.rec-item__actions { display: flex; gap: var(--fc-space-1); }
.fc-chip {
font-size: 7px;
padding: 3px 6px;
border-radius: 2px;
letter-spacing: .05em;
}
.chip--ok { background: rgba(34,197,94,.2); color: var(--fc-green); border: 1px solid var(--fc-green); }
.chip--late { background: rgba(255,180,0,.2); color: var(--fc-gold); border: 1px solid var(--fc-gold); }
.chip--pending { background: rgba(255,59,107,.1); color: var(--fc-red); border: 1px solid var(--fc-red); }
</style>