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 { 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>
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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"
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user