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:
@@ -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>
|
||||
@@ -2,6 +2,7 @@
|
||||
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())
|
||||
@@ -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 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
|
||||
@@ -33,7 +29,6 @@ function startEdit(id: number) {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -45,13 +40,11 @@ async function payBill(billId: number) {
|
||||
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)
|
||||
@@ -59,7 +52,6 @@ async function submit() {
|
||||
} else {
|
||||
await store.create(form.value)
|
||||
form.value = blank()
|
||||
initialRaw.value = ''
|
||||
}
|
||||
} catch (e: any) {
|
||||
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" />
|
||||
</div>
|
||||
</template>
|
||||
<input
|
||||
v-model="initialRaw"
|
||||
placeholder="Saldo inicial (ex: 1.500,00)"
|
||||
<CurrencyInput
|
||||
v-model="form.initial_balance"
|
||||
class="fc-input fc-acc-form__balance"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ref, computed, onMounted } from 'vue'
|
||||
import { useRecurringStore } from '@/stores/recurring'
|
||||
import { useCategoriesStore } from '@/stores/categories'
|
||||
import NeonPanel from '@/components/NeonPanel.vue'
|
||||
import CurrencyInput from '@/components/CurrencyInput.vue'
|
||||
|
||||
const store = useRecurringStore()
|
||||
const catStore = useCategoriesStore()
|
||||
@@ -23,32 +24,24 @@ const blank = (): Omit<typeof store.items[0], 'id' | 'active' | 'created_at' | '
|
||||
|
||||
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)
|
||||
@@ -56,7 +49,6 @@ async function submit() {
|
||||
} else {
|
||||
await store.create(form.value)
|
||||
form.value = blank()
|
||||
amountRaw.value = ''
|
||||
}
|
||||
await store.fetchMonthlyStatus(currentMonth.value)
|
||||
} catch (e: any) {
|
||||
@@ -103,7 +95,7 @@ function getStatus(id: number) {
|
||||
<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" />
|
||||
<CurrencyInput v-model="form.expected_amount" 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" />
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useCategoriesStore } from '@/stores/categories'
|
||||
import { useAccountsStore } from '@/stores/accounts'
|
||||
import NeonPanel from '@/components/NeonPanel.vue'
|
||||
import MonthSwitcher from '@/components/MonthSwitcher.vue'
|
||||
import CurrencyInput from '@/components/CurrencyInput.vue'
|
||||
|
||||
const store = useTransactionsStore()
|
||||
const catStore = useCategoriesStore()
|
||||
@@ -37,31 +38,23 @@ const blank = (): TransactionInput => ({
|
||||
const form = ref(blank())
|
||||
const editId = ref<number | 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) {
|
||||
const t = store.transactions.find((x) => x.id === id)
|
||||
if (!t || t.source !== 'manual') return
|
||||
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 }
|
||||
amountRaw.value = t.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.amount = parseAmount(amountRaw.value)
|
||||
try {
|
||||
if (editId.value !== null) {
|
||||
await store.update(editId.value, form.value)
|
||||
@@ -69,7 +62,6 @@ async function submit() {
|
||||
} else {
|
||||
await store.create(form.value)
|
||||
form.value = blank()
|
||||
amountRaw.value = ''
|
||||
}
|
||||
} catch (e: any) {
|
||||
formError.value = e.message
|
||||
@@ -117,9 +109,8 @@ function monthLabel(ym: string) {
|
||||
<option value="expense">Gasto</option>
|
||||
<option value="income">Receita</option>
|
||||
</select>
|
||||
<input
|
||||
v-model="amountRaw"
|
||||
placeholder="150,90"
|
||||
<CurrencyInput
|
||||
v-model="form.amount"
|
||||
required
|
||||
class="fc-input fc-tx-form__amount"
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user