feat: CurrencyInput em todos os campos de valor monetário

Input digit-by-digit em centavos com formatação BRL automática.
Substitui amountRaw/initialRaw + parseAmount nas 3 views.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
This commit is contained in:
2026-05-27 15:42:37 -03:00
co-authored by Claude Sonnet 4.6
parent 62aea56d72
commit 5c55da8a4a
4 changed files with 103 additions and 34 deletions
+95
View File
@@ -0,0 +1,95 @@
<script setup lang="ts">
import { ref, watch, computed } from 'vue'
const props = defineProps<{ modelValue: number }>()
const emit = defineEmits<{ 'update:modelValue': [value: number] }>()
const brlFormatter = new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' })
function formatFromCents(d: string) {
return brlFormatter.format(Number(d || '0') / 100)
}
function toCentsDigits(n: number) {
return String(Math.max(0, Math.round((Number.isFinite(n) ? n : 0) * 100)))
}
const digits = ref('')
const isEditing = ref(false)
watch(
() => props.modelValue,
(val) => {
if (isEditing.value) return
digits.value = val ? toCentsDigits(val) : ''
},
{ immediate: true },
)
function commit(next: string) {
digits.value = next
emit('update:modelValue', next ? Number(next) / 100 : 0)
}
function appendDigit(d: string) {
commit((digits.value + d).replace(/^0+(?=\d)/, ''))
}
function backspace() {
commit(digits.value.slice(0, -1))
}
const isHandlingInput = ref(false)
function onBeforeInput(e: InputEvent) {
isHandlingInput.value = true
if (e.inputType === 'insertText' && e.data) {
const chunk = e.data.replace(/\D/g, '')
if (chunk) {
e.preventDefault()
commit((digits.value + chunk).replace(/^0+(?=\d)/, ''))
return
}
}
if (e.inputType === 'deleteContentBackward') {
e.preventDefault()
backspace()
}
}
function onKeyDown(e: KeyboardEvent) {
if (e.key === 'Backspace') { e.preventDefault(); backspace(); return }
if (/^\d$/.test(e.key)) { e.preventDefault(); appendDigit(e.key); return }
if (['Enter', 'Escape', 'Tab', 'ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(e.key)) return
if (!e.ctrlKey && !e.metaKey) e.preventDefault()
}
function onPaste(e: ClipboardEvent) {
const pasted = (e.clipboardData?.getData('text') ?? '').replace(/\D/g, '')
if (!pasted) return
e.preventDefault()
commit((digits.value + pasted).replace(/^0+(?=\d)/, ''))
}
function onChange(e: Event) {
if (isHandlingInput.value) { isHandlingInput.value = false; return }
const extracted = (e.target as HTMLInputElement).value.replace(/\D/g, '')
if (extracted !== digits.value) commit(extracted.replace(/^0+(?=\d)/, '') || '')
}
const displayValue = computed(() => digits.value ? formatFromCents(digits.value) : '')
</script>
<template>
<input
type="text"
inputmode="numeric"
:value="displayValue"
placeholder="R$ 0,00"
@beforeinput="onBeforeInput"
@keydown="onKeyDown"
@paste="onPaste"
@change="onChange"
@focus="isEditing = true"
@blur="isEditing = false"
/>
</template>
+3 -12
View File
@@ -2,6 +2,7 @@
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { useAccountsStore, type AccountInput } from '@/stores/accounts' import { useAccountsStore, type AccountInput } from '@/stores/accounts'
import NeonPanel from '@/components/NeonPanel.vue' import NeonPanel from '@/components/NeonPanel.vue'
import CurrencyInput from '@/components/CurrencyInput.vue'
const store = useAccountsStore() const store = useAccountsStore()
onMounted(() => store.fetchAll()) onMounted(() => store.fetchAll())
@@ -16,13 +17,8 @@ const typeLabels: Record<string, string> = {
const blank = (): AccountInput => ({ name: '', type: 'checking', initial_balance: 0, yield_type: 'none', closing_day: null, due_day: null }) const blank = (): AccountInput => ({ name: '', type: 'checking', initial_balance: 0, yield_type: 'none', closing_day: null, due_day: null })
const form = ref(blank()) const form = ref(blank())
const editId = ref<number | null>(null) const editId = ref<number | null>(null)
const initialRaw = ref('')
const formError = ref<string | null>(null) const formError = ref<string | null>(null)
function parseAmount(s: string) {
return parseFloat(s.replace(/\./g, '').replace(',', '.')) || 0
}
function startEdit(id: number) { function startEdit(id: number) {
const a = store.accounts.find((x) => x.id === id) const a = store.accounts.find((x) => x.id === id)
if (!a) return if (!a) return
@@ -33,7 +29,6 @@ function startEdit(id: number) {
closing_day: a.closing_day ?? null, closing_day: a.closing_day ?? null,
due_day: a.due_day ?? null, due_day: a.due_day ?? null,
} }
initialRaw.value = a.initial_balance.toLocaleString('pt-BR', { minimumFractionDigits: 2 })
formError.value = null formError.value = null
} }
@@ -45,13 +40,11 @@ async function payBill(billId: number) {
function cancelEdit() { function cancelEdit() {
editId.value = null editId.value = null
form.value = blank() form.value = blank()
initialRaw.value = ''
formError.value = null formError.value = null
} }
async function submit() { async function submit() {
formError.value = null formError.value = null
form.value.initial_balance = parseAmount(initialRaw.value)
try { try {
if (editId.value !== null) { if (editId.value !== null) {
await store.update(editId.value, form.value) await store.update(editId.value, form.value)
@@ -59,7 +52,6 @@ async function submit() {
} else { } else {
await store.create(form.value) await store.create(form.value)
form.value = blank() form.value = blank()
initialRaw.value = ''
} }
} catch (e: any) { } catch (e: any) {
formError.value = e.message formError.value = e.message
@@ -110,9 +102,8 @@ function totalBalance() {
<input type="number" v-model.number="form.due_day" min="1" max="28" class="fc-input fc-acc-form__day" /> <input type="number" v-model.number="form.due_day" min="1" max="28" class="fc-input fc-acc-form__day" />
</div> </div>
</template> </template>
<input <CurrencyInput
v-model="initialRaw" v-model="form.initial_balance"
placeholder="Saldo inicial (ex: 1.500,00)"
class="fc-input fc-acc-form__balance" class="fc-input fc-acc-form__balance"
/> />
</div> </div>
+2 -10
View File
@@ -3,6 +3,7 @@ import { ref, computed, onMounted } from 'vue'
import { useRecurringStore } from '@/stores/recurring' import { useRecurringStore } from '@/stores/recurring'
import { useCategoriesStore } from '@/stores/categories' import { useCategoriesStore } from '@/stores/categories'
import NeonPanel from '@/components/NeonPanel.vue' import NeonPanel from '@/components/NeonPanel.vue'
import CurrencyInput from '@/components/CurrencyInput.vue'
const store = useRecurringStore() const store = useRecurringStore()
const catStore = useCategoriesStore() const catStore = useCategoriesStore()
@@ -23,32 +24,24 @@ const blank = (): Omit<typeof store.items[0], 'id' | 'active' | 'created_at' | '
const form = ref(blank()) const form = ref(blank())
const editId = ref<number | null>(null) const editId = ref<number | null>(null)
const amountRaw = ref('')
const formError = ref<string | null>(null) const formError = ref<string | null>(null)
function parseAmount(s: string) {
return parseFloat(s.replace(/\./g, '').replace(',', '.')) || 0
}
function startEdit(id: number) { function startEdit(id: number) {
const item = store.items.find((x) => x.id === id) const item = store.items.find((x) => x.id === id)
if (!item) return if (!item) return
editId.value = id 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 } 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 formError.value = null
} }
function cancelEdit() { function cancelEdit() {
editId.value = null editId.value = null
form.value = blank() form.value = blank()
amountRaw.value = ''
formError.value = null formError.value = null
} }
async function submit() { async function submit() {
formError.value = null formError.value = null
form.value.expected_amount = parseAmount(amountRaw.value)
try { try {
if (editId.value !== null) { if (editId.value !== null) {
await store.update(editId.value, form.value) await store.update(editId.value, form.value)
@@ -56,7 +49,6 @@ async function submit() {
} else { } else {
await store.create(form.value) await store.create(form.value)
form.value = blank() form.value = blank()
amountRaw.value = ''
} }
await store.fetchMonthlyStatus(currentMonth.value) await store.fetchMonthlyStatus(currentMonth.value)
} catch (e: any) { } catch (e: any) {
@@ -103,7 +95,7 @@ function getStatus(id: number) {
<form class="rec-form" @submit.prevent="submit"> <form class="rec-form" @submit.prevent="submit">
<div class="rec-form__row"> <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="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" /> <CurrencyInput v-model="form.expected_amount" required class="fc-input rec-form__amount" />
<div class="fc-label rec-form__day-wrap"> <div class="fc-label rec-form__day-wrap">
Dia Dia
<input type="number" v-model.number="form.day_of_month" min="1" max="31" class="fc-input rec-form__day" /> <input type="number" v-model.number="form.day_of_month" min="1" max="31" class="fc-input rec-form__day" />
+3 -12
View File
@@ -5,6 +5,7 @@ import { useCategoriesStore } from '@/stores/categories'
import { useAccountsStore } from '@/stores/accounts' import { useAccountsStore } from '@/stores/accounts'
import NeonPanel from '@/components/NeonPanel.vue' import NeonPanel from '@/components/NeonPanel.vue'
import MonthSwitcher from '@/components/MonthSwitcher.vue' import MonthSwitcher from '@/components/MonthSwitcher.vue'
import CurrencyInput from '@/components/CurrencyInput.vue'
const store = useTransactionsStore() const store = useTransactionsStore()
const catStore = useCategoriesStore() const catStore = useCategoriesStore()
@@ -37,31 +38,23 @@ const blank = (): TransactionInput => ({
const form = ref(blank()) const form = ref(blank())
const editId = ref<number | null>(null) const editId = ref<number | null>(null)
const formError = ref<string | null>(null) const formError = ref<string | null>(null)
const amountRaw = ref('')
function parseAmount(s: string): number {
return parseFloat(s.replace(/\./g, '').replace(',', '.')) || 0
}
function startEdit(id: number) { function startEdit(id: number) {
const t = store.transactions.find((x) => x.id === id) const t = store.transactions.find((x) => x.id === id)
if (!t || t.source !== 'manual') return if (!t || t.source !== 'manual') return
editId.value = id editId.value = id
form.value = { date: t.date, amount: t.amount, description: t.description, type: t.type as any, category_id: t.category_id, account_id: t.account_id } form.value = { date: t.date, amount: t.amount, description: t.description, type: t.type as any, category_id: t.category_id, account_id: t.account_id }
amountRaw.value = t.amount.toLocaleString('pt-BR', { minimumFractionDigits: 2 })
formError.value = null formError.value = null
} }
function cancelEdit() { function cancelEdit() {
editId.value = null editId.value = null
form.value = blank() form.value = blank()
amountRaw.value = ''
formError.value = null formError.value = null
} }
async function submit() { async function submit() {
formError.value = null formError.value = null
form.value.amount = parseAmount(amountRaw.value)
try { try {
if (editId.value !== null) { if (editId.value !== null) {
await store.update(editId.value, form.value) await store.update(editId.value, form.value)
@@ -69,7 +62,6 @@ async function submit() {
} else { } else {
await store.create(form.value) await store.create(form.value)
form.value = blank() form.value = blank()
amountRaw.value = ''
} }
} catch (e: any) { } catch (e: any) {
formError.value = e.message formError.value = e.message
@@ -117,9 +109,8 @@ function monthLabel(ym: string) {
<option value="expense">Gasto</option> <option value="expense">Gasto</option>
<option value="income">Receita</option> <option value="income">Receita</option>
</select> </select>
<input <CurrencyInput
v-model="amountRaw" v-model="form.amount"
placeholder="150,90"
required required
class="fc-input fc-tx-form__amount" class="fc-input fc-tx-form__amount"
/> />