feat(#37): módulo de cartão de crédito com faturas mensais
- Migration 011: closing_day/due_day em accounts + tabela credit_bills - CreditBillService: cria/atualiza fatura corrente on-demand no GET /accounts - Widget FATURA ATUAL no dashboard com total e botão PAGAR - AccountsView: campos closing_day/due_day para contas credit + painel fatura - Dashboard: current_bills lista faturas não pagas de todos os cartões Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
This commit is contained in:
@@ -2,6 +2,19 @@ import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { api } from '@/services/api'
|
||||
|
||||
export interface CreditBill {
|
||||
id: number
|
||||
account_id: number
|
||||
account_name: string
|
||||
period_start: string
|
||||
period_end: string
|
||||
due_date: string
|
||||
total: number
|
||||
paid: boolean
|
||||
paid_at?: string
|
||||
payment_account_id?: number
|
||||
}
|
||||
|
||||
export interface Account {
|
||||
id: number
|
||||
name: string
|
||||
@@ -10,6 +23,9 @@ export interface Account {
|
||||
balance: number
|
||||
yield_type: 'none' | 'cdi' | 'variable'
|
||||
last_yield_date?: string
|
||||
closing_day?: number
|
||||
due_day?: number
|
||||
current_bill?: CreditBill
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
@@ -19,6 +35,8 @@ export interface AccountInput {
|
||||
type: string
|
||||
initial_balance: number
|
||||
yield_type: string
|
||||
closing_day?: number | null
|
||||
due_day?: number | null
|
||||
}
|
||||
|
||||
export const useAccountsStore = defineStore('accounts', () => {
|
||||
@@ -56,5 +74,10 @@ export const useAccountsStore = defineStore('accounts', () => {
|
||||
accounts.value = accounts.value.filter((a) => a.id !== id)
|
||||
}
|
||||
|
||||
return { accounts, loading, error, fetchAll, create, update, remove }
|
||||
async function payBill(billId: number, paymentAccountId?: number) {
|
||||
await api.post(`/bills/${billId}/pay`, { payment_account_id: paymentAccountId ?? null })
|
||||
await fetchAll()
|
||||
}
|
||||
|
||||
return { accounts, loading, error, fetchAll, create, update, remove, payBill }
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { api } from '@/services/api'
|
||||
import type { CreditBill } from './accounts'
|
||||
|
||||
export interface CategoryTotal {
|
||||
category_id: number | null
|
||||
@@ -44,6 +45,7 @@ export interface DashboardData {
|
||||
recent_transactions: RecentTransaction[]
|
||||
pending_recurring: number
|
||||
pending_income_recurrings: PendingIncome[]
|
||||
current_bills: CreditBill[]
|
||||
}
|
||||
|
||||
export const useDashboardStore = defineStore('dashboard', () => {
|
||||
|
||||
@@ -13,7 +13,7 @@ const typeLabels: Record<string, string> = {
|
||||
credit: 'Cartão de Crédito',
|
||||
}
|
||||
|
||||
const blank = (): AccountInput => ({ name: '', type: 'checking', initial_balance: 0, yield_type: 'none' })
|
||||
const blank = (): AccountInput => ({ name: '', type: 'checking', initial_balance: 0, yield_type: 'none', closing_day: null, due_day: null })
|
||||
const form = ref(blank())
|
||||
const editId = ref<number | null>(null)
|
||||
const initialRaw = ref('')
|
||||
@@ -27,11 +27,21 @@ 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' }
|
||||
form.value = {
|
||||
name: a.name, type: a.type, initial_balance: a.initial_balance,
|
||||
yield_type: a.yield_type ?? 'none',
|
||||
closing_day: a.closing_day ?? null,
|
||||
due_day: a.due_day ?? null,
|
||||
}
|
||||
initialRaw.value = a.initial_balance.toLocaleString('pt-BR', { minimumFractionDigits: 2 })
|
||||
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()
|
||||
@@ -90,6 +100,16 @@ function totalBalance() {
|
||||
<option value="cdi">CDI automático</option>
|
||||
<option value="variable">Renda variável</option>
|
||||
</select>
|
||||
<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>
|
||||
<input
|
||||
v-model="initialRaw"
|
||||
placeholder="Saldo inicial (ex: 1.500,00)"
|
||||
@@ -147,6 +167,32 @@ function totalBalance() {
|
||||
<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>
|
||||
|
||||
@@ -257,6 +303,17 @@ function totalBalance() {
|
||||
.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;
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ref, computed, onMounted } from 'vue'
|
||||
import { useDashboardStore } from '@/stores/dashboard'
|
||||
import { useGameStore } from '@/stores/game'
|
||||
import { useRecurringStore } from '@/stores/recurring'
|
||||
import { useAccountsStore } from '@/stores/accounts'
|
||||
import NeonPanel from '@/components/NeonPanel.vue'
|
||||
import XPBar from '@/components/XPBar.vue'
|
||||
import CharacterSprite from '@/components/CharacterSprite.vue'
|
||||
@@ -11,6 +12,7 @@ import MonthSwitcher from '@/components/MonthSwitcher.vue'
|
||||
const dash = useDashboardStore()
|
||||
const game = useGameStore()
|
||||
const recurring = useRecurringStore()
|
||||
const accountsStore = useAccountsStore()
|
||||
const currentMonth = ref(new Date().toISOString().slice(0, 7))
|
||||
|
||||
onMounted(() => dash.fetch(currentMonth.value))
|
||||
@@ -61,6 +63,13 @@ const pendingIncome = computed(() => dash.data?.pending_income_recurrings ?? [])
|
||||
const showIncomeWidget = computed(() => isCurrentMonth.value && pendingIncome.value.length > 0)
|
||||
const showIncomeConfirmButtons = computed(() => dayOfMonth <= 5)
|
||||
|
||||
const currentBills = computed(() => dash.data?.current_bills?.filter(b => !b.paid) ?? [])
|
||||
|
||||
async function payBill(billId: number) {
|
||||
await accountsStore.payBill(billId)
|
||||
await dash.fetch(currentMonth.value)
|
||||
}
|
||||
|
||||
async function confirmIncome(id: number) {
|
||||
await recurring.confirmIncome(id, currentMonth.value)
|
||||
await dash.fetch(currentMonth.value)
|
||||
@@ -249,6 +258,21 @@ async function markLate(id: number) {
|
||||
</div>
|
||||
</div>
|
||||
</NeonPanel>
|
||||
<!-- Credit card bills -->
|
||||
<NeonPanel v-if="currentBills.length > 0" title="FATURAS ABERTAS" :variant="'danger'">
|
||||
<div class="bill-list">
|
||||
<div v-for="bill in currentBills" :key="bill.id" class="bill-item">
|
||||
<div class="bill-item__info">
|
||||
<span class="fc-body bill-item__name">{{ bill.account_name }}</span>
|
||||
<span class="fc-mono bill-item__due" style="color:var(--fc-red)">vence {{ bill.due_date }}</span>
|
||||
</div>
|
||||
<div class="bill-item__right">
|
||||
<span class="fc-mono bill-item__total" style="color:var(--fc-red);font-weight:700">{{ fmt(bill.total) }}</span>
|
||||
<button class="fc-btn fc-btn--sm fc-btn--ghost" @click="payBill(bill.id)">PAGAR</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</NeonPanel>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -338,6 +362,15 @@ async function markLate(id: number) {
|
||||
|
||||
.empty { font-size: 8px; color: var(--fc-text-dim); padding: 8px 0; }
|
||||
|
||||
/* Bill widget */
|
||||
.bill-list { display: flex; flex-direction: column; gap: 10px; }
|
||||
.bill-item { display: flex; justify-content: space-between; align-items: center; gap: var(--fc-space-2); flex-wrap: wrap; }
|
||||
.bill-item__info { display: flex; flex-direction: column; gap: 3px; }
|
||||
.bill-item__name { font-size: 13px; font-weight: 500; }
|
||||
.bill-item__due { font-size: 10px; }
|
||||
.bill-item__right { display: flex; align-items: center; gap: var(--fc-space-2); }
|
||||
.bill-item__total { font-size: 15px; }
|
||||
|
||||
/* Income widget */
|
||||
.dash-income-panel { margin-bottom: 0; }
|
||||
.dash-alert--salary { background: rgba(255,180,0,.08); border-color: var(--fc-gold); }
|
||||
|
||||
Reference in New Issue
Block a user