Refine transaction form validation UI
This commit is contained in:
@@ -49,4 +49,24 @@ button {
|
||||
.segmented-button-active {
|
||||
@apply bg-ink text-white;
|
||||
}
|
||||
|
||||
.transaction-form-row {
|
||||
@apply grid grid-cols-[4.25rem_1fr] items-center gap-3 border-b border-line py-3 transition-colors duration-150;
|
||||
}
|
||||
|
||||
.transaction-form-row:focus-within {
|
||||
@apply border-brand-600;
|
||||
}
|
||||
|
||||
.transaction-form-row-error {
|
||||
@apply border-red-500;
|
||||
}
|
||||
|
||||
.transaction-form-control {
|
||||
@apply w-full border-0 bg-transparent px-0 py-2 text-sm font-semibold outline-none shadow-none focus:ring-0;
|
||||
}
|
||||
|
||||
.transaction-form-select {
|
||||
@apply flex min-h-10 w-full items-center justify-between border-0 bg-transparent px-0 py-2 text-left text-sm font-semibold outline-none shadow-none focus:ring-0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
class="field flex min-h-[50px] items-center text-left font-semibold"
|
||||
:class="buttonClass"
|
||||
aria-haspopup="dialog"
|
||||
:aria-expanded="open"
|
||||
@click="open = true"
|
||||
@@ -82,6 +82,7 @@ import { addMonths, format, parse } from 'date-fns'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: string
|
||||
buttonClass?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -91,6 +92,7 @@ const emit = defineEmits<{
|
||||
const weekdays = ['일', '월', '화', '수', '목', '금', '토']
|
||||
const open = ref(false)
|
||||
const calendarDate = ref(parse(props.modelValue, 'yyyy-MM-dd', new Date()))
|
||||
const buttonClass = computed(() => props.buttonClass || 'field flex min-h-[50px] items-center text-left font-semibold')
|
||||
|
||||
const calendarYear = computed(() => calendarDate.value.getFullYear())
|
||||
const calendarMonth = computed(() => calendarDate.value.getMonth())
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
<template>
|
||||
<div>
|
||||
<button
|
||||
ref="buttonRef"
|
||||
type="button"
|
||||
class="field flex min-h-[50px] items-center justify-between text-left"
|
||||
:class="buttonClass"
|
||||
aria-haspopup="dialog"
|
||||
:aria-expanded="open"
|
||||
@click="open = true"
|
||||
@@ -82,8 +83,10 @@ const props = withDefaults(defineProps<{
|
||||
options: Option[]
|
||||
title: string
|
||||
placeholder?: string
|
||||
buttonClass?: string
|
||||
}>(), {
|
||||
placeholder: '선택'
|
||||
placeholder: '선택',
|
||||
buttonClass: 'field flex min-h-[50px] items-center justify-between text-left'
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -91,6 +94,7 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const open = ref(false)
|
||||
const buttonRef = ref<HTMLButtonElement | null>(null)
|
||||
const selected = computed(() => props.options.find(option => option.value === props.modelValue))
|
||||
|
||||
function select(value: string) {
|
||||
@@ -98,4 +102,10 @@ function select(value: string) {
|
||||
open.value = false
|
||||
}
|
||||
|
||||
function focus() {
|
||||
buttonRef.value?.focus()
|
||||
open.value = true
|
||||
}
|
||||
|
||||
defineExpose({ focus })
|
||||
</script>
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
- 거래 목록 행을 날짜 그룹, 카테고리 아이콘 자리, 기본 2줄 정보, 메모가 있을 때만 보이는 3번째 줄 구조로 변경했다.
|
||||
- 메모 입력과 저장 길이를 최대 60자로 제한했다.
|
||||
- 거래 입력/수정에서 시간 입력을 제거하고 날짜 바텀 시트 선택으로 변경했다.
|
||||
- 금액 입력 기본값을 제거하고 placeholder로 안내하도록 변경했다.
|
||||
- 금액 입력 기본값을 제거했다.
|
||||
- 거래 목록의 카테고리 표시는 하위 카테고리명 중심으로 단순화했다.
|
||||
- 내역 화면 상단을 헤더, 월 요약, 주간 날짜 스트립 구조로 개편했다.
|
||||
- 방향 이동 버튼을 `assets/icons`의 SVG 화살표로 통일했다.
|
||||
@@ -50,3 +50,6 @@
|
||||
- 설정의 계정 관리 화면에서 이름 수정과 비밀번호 변경 기능을 구현했다.
|
||||
- 내역 화면의 주간 날짜 스트립에 좌우 슬라이드 전환 애니메이션을 추가했다.
|
||||
- 거래 입력/수정 화면의 자산, 분류, 금액, 내용 placeholder를 제거하고 메모/위치 영역을 같은 입력 리스트 안의 얇은 행으로 정리했다.
|
||||
- 거래 입력/수정 화면의 상단 저장 버튼을 제거하고 하단 저장/취소 2:1 버튼 구조로 변경했다.
|
||||
- 거래 입력/수정 화면에 필수값 누락 토스트와 해당 입력 줄 포커스/강조 피드백을 추가했다.
|
||||
- 거래 입력/수정 행 스타일을 박스형 입력에서 밑줄형 입력으로 변경했다.
|
||||
|
||||
@@ -18,6 +18,9 @@
|
||||
- 날짜 선택은 모바일 친화적인 바텀 시트 캘린더를 사용한다.
|
||||
- 자산, 분류, 금액, 내용 입력은 placeholder 문구를 넣지 않고 비어 있는 입력 상태로 둔다.
|
||||
- 금액 입력은 기본값 `0`을 채우지 않는다.
|
||||
- 거래 입력/수정 저장 버튼은 하단에만 두고, `저장하기`와 `취소`를 2:1 비율로 배치한다.
|
||||
- 필수값 없이 저장을 누르면 토스트로 부족한 항목을 알려주고 해당 입력 줄에 포커스와 강조선을 준다.
|
||||
- 거래 입력 행은 위아래 박스 라인 대신 각 입력란 하단 밑줄만 사용하고, 포커스 중인 줄만 포인트 색상으로 강조한다.
|
||||
- 셀렉트 박스보다 모바일 친화적인 바텀 시트를 우선한다.
|
||||
- 활성/비활성 같은 상태는 텍스트 버튼보다 토글처럼 현재 상태가 보이는 UI를 쓴다.
|
||||
- 통계 화면은 그래프보다 숫자와 분류별 리스트를 우선한다.
|
||||
|
||||
@@ -12,9 +12,7 @@
|
||||
</button>
|
||||
<h1 class="text-lg font-bold">{{ form.type === 'income' ? '수입' : '지출' }}</h1>
|
||||
</div>
|
||||
<button type="button" class="text-sm font-bold text-brand-700" :disabled="pending" @click="submit">
|
||||
저장
|
||||
</button>
|
||||
<div class="h-9 w-9" aria-hidden="true" />
|
||||
</header>
|
||||
|
||||
<form class="bg-white px-4 py-4" @submit.prevent="submit">
|
||||
@@ -23,48 +21,67 @@
|
||||
<button type="button" class="segmented-button border border-line py-2.5" :class="{ 'border-red-500 text-red-500': form.type === 'expense' }" @click="setType('expense')">지출</button>
|
||||
</div>
|
||||
|
||||
<div class="divide-y divide-line border-y border-line">
|
||||
<div class="grid grid-cols-[4.25rem_1fr] items-center gap-3 py-3">
|
||||
<div class="bg-white">
|
||||
<div class="transaction-form-row">
|
||||
<span class="text-sm font-semibold text-muted">날짜</span>
|
||||
<BottomSheetDatePicker v-model="form.occurredAt" />
|
||||
<BottomSheetDatePicker v-model="form.occurredAt" button-class="transaction-form-select" />
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-[4.25rem_1fr] items-center gap-3 py-3">
|
||||
<div class="transaction-form-row" :class="{ 'transaction-form-row-error': invalidField === 'assetId' }">
|
||||
<span class="text-sm font-semibold text-muted">자산</span>
|
||||
<BottomSheetSelect
|
||||
ref="assetSelectRef"
|
||||
v-model="form.assetId"
|
||||
title="자산 선택"
|
||||
placeholder=""
|
||||
button-class="transaction-form-select"
|
||||
:options="assetOptions"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-[4.25rem_1fr] items-center gap-3 py-3">
|
||||
<div class="transaction-form-row" :class="{ 'transaction-form-row-error': invalidField === 'categoryId' }">
|
||||
<span class="text-sm font-semibold text-muted">분류</span>
|
||||
<BottomSheetSelect
|
||||
ref="categorySelectRef"
|
||||
v-model="form.categoryId"
|
||||
title="분류 선택"
|
||||
placeholder=""
|
||||
button-class="transaction-form-select"
|
||||
:options="categoryOptions"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-[4.25rem_1fr] items-center gap-3 py-3">
|
||||
<div class="transaction-form-row" :class="{ 'transaction-form-row-error': invalidField === 'amount' }">
|
||||
<label class="text-sm font-semibold text-muted" for="amount">금액</label>
|
||||
<input id="amount" v-model.number="form.amount" class="field border-0 bg-transparent text-right text-lg font-bold shadow-none focus:ring-0" inputmode="numeric" min="1" type="number" required>
|
||||
<input
|
||||
id="amount"
|
||||
ref="amountInputRef"
|
||||
v-model.number="form.amount"
|
||||
class="transaction-form-control text-right text-lg font-bold"
|
||||
inputmode="numeric"
|
||||
min="1"
|
||||
type="number"
|
||||
@focus="invalidField = ''"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-[4.25rem_1fr] items-center gap-3 py-3">
|
||||
<div class="transaction-form-row" :class="{ 'transaction-form-row-error': invalidField === 'title' }">
|
||||
<label class="text-sm font-semibold text-muted" for="title">내용</label>
|
||||
<input id="title" v-model="form.title" class="field border-0 bg-transparent font-semibold shadow-none focus:ring-0" required>
|
||||
<input
|
||||
id="title"
|
||||
ref="titleInputRef"
|
||||
v-model="form.title"
|
||||
class="transaction-form-control"
|
||||
@focus="invalidField = ''"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-[4.25rem_1fr] items-start gap-3 py-3">
|
||||
<div class="transaction-form-row items-start">
|
||||
<label class="pt-2 text-sm font-semibold text-muted" for="memo">메모</label>
|
||||
<textarea id="memo" v-model="form.memo" class="field min-h-12 resize-none border-0 bg-transparent shadow-none focus:ring-0" maxlength="60"></textarea>
|
||||
<textarea id="memo" v-model="form.memo" class="transaction-form-control min-h-12 resize-none" maxlength="60"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-[4.25rem_1fr] items-center gap-3 py-3">
|
||||
<div class="transaction-form-row">
|
||||
<span class="text-sm font-semibold text-muted">위치</span>
|
||||
<div class="flex min-h-10 items-center justify-end gap-3">
|
||||
<span v-if="form.latitude" class="truncate text-right text-sm font-semibold text-muted">
|
||||
@@ -76,10 +93,21 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="btn-primary mt-5 w-full" type="submit" :disabled="pending || !canSubmit">
|
||||
<div class="mt-6 grid grid-cols-[2fr_1fr] gap-2">
|
||||
<button class="btn-primary min-h-12 text-base" type="submit" :disabled="pending">
|
||||
{{ pending ? '저장 중...' : '저장하기' }}
|
||||
</button>
|
||||
<button class="btn-secondary min-h-12 border-slate-300 text-base" type="button" @click="goBack">
|
||||
취소
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<Transition enter-active-class="transition duration-150 ease-out" enter-from-class="translate-y-2 opacity-0" enter-to-class="translate-y-0 opacity-100" leave-active-class="transition duration-150 ease-in" leave-from-class="translate-y-0 opacity-100" leave-to-class="translate-y-2 opacity-0">
|
||||
<div v-if="toastMessage" class="fixed inset-x-4 bottom-24 z-50 mx-auto max-w-sm rounded-md bg-ink px-4 py-3 text-center text-sm font-semibold text-white shadow-soft">
|
||||
{{ toastMessage }}
|
||||
</div>
|
||||
</Transition>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -88,6 +116,13 @@ type TransactionType = 'income' | 'expense'
|
||||
|
||||
const router = useRouter()
|
||||
const pending = ref(false)
|
||||
const invalidField = ref('')
|
||||
const toastMessage = ref('')
|
||||
let toastTimer: ReturnType<typeof setTimeout> | null = null
|
||||
const assetSelectRef = ref<{ focus: () => void } | null>(null)
|
||||
const categorySelectRef = ref<{ focus: () => void } | null>(null)
|
||||
const amountInputRef = ref<HTMLInputElement | null>(null)
|
||||
const titleInputRef = ref<HTMLInputElement | null>(null)
|
||||
const { data: assetsData } = await useFetch('/api/assets')
|
||||
const { data: categoriesData } = await useFetch('/api/categories')
|
||||
|
||||
@@ -125,12 +160,9 @@ watch(selectableCategories, (items: any[]) => {
|
||||
if (form.categoryId && !items.some(item => item.id === form.categoryId)) form.categoryId = ''
|
||||
}, { immediate: true })
|
||||
|
||||
const canSubmit = computed(() => {
|
||||
return Boolean(form.assetId && form.categoryId && form.amount && form.amount > 0 && form.title.trim())
|
||||
})
|
||||
|
||||
function setType(type: TransactionType) {
|
||||
form.type = type
|
||||
invalidField.value = ''
|
||||
}
|
||||
|
||||
function captureLocation() {
|
||||
@@ -148,6 +180,7 @@ function clearLocation() {
|
||||
|
||||
async function submit() {
|
||||
if (pending.value) return
|
||||
if (!validateForm()) return
|
||||
pending.value = true
|
||||
try {
|
||||
await $fetch('/api/transactions', {
|
||||
@@ -175,4 +208,24 @@ async function submit() {
|
||||
function goBack() {
|
||||
router.back()
|
||||
}
|
||||
|
||||
function validateForm() {
|
||||
if (!form.assetId) return showValidation('assetId', '자산을 선택해 주세요.', () => assetSelectRef.value?.focus())
|
||||
if (!form.categoryId) return showValidation('categoryId', '분류를 선택해 주세요.', () => categorySelectRef.value?.focus())
|
||||
if (!form.amount || form.amount < 1) return showValidation('amount', '금액을 입력해 주세요.', () => amountInputRef.value?.focus())
|
||||
if (!form.title.trim()) return showValidation('title', '내용을 입력해 주세요.', () => titleInputRef.value?.focus())
|
||||
invalidField.value = ''
|
||||
return true
|
||||
}
|
||||
|
||||
function showValidation(field: string, message: string, focus: () => void) {
|
||||
invalidField.value = field
|
||||
toastMessage.value = message
|
||||
if (toastTimer) clearTimeout(toastTimer)
|
||||
toastTimer = setTimeout(() => {
|
||||
toastMessage.value = ''
|
||||
}, 2200)
|
||||
nextTick(() => focus())
|
||||
return false
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -12,9 +12,7 @@
|
||||
</button>
|
||||
<h1 class="text-lg font-bold">{{ form.type === 'income' ? '수입' : '지출' }}</h1>
|
||||
</div>
|
||||
<button type="button" class="text-sm font-bold text-brand-700" :disabled="pending" @click="save">
|
||||
저장
|
||||
</button>
|
||||
<div class="h-9 w-9" aria-hidden="true" />
|
||||
</header>
|
||||
|
||||
<form class="bg-white px-4 py-4" @submit.prevent="save">
|
||||
@@ -23,48 +21,67 @@
|
||||
<button type="button" class="segmented-button border border-line py-2.5" :class="{ 'border-red-500 text-red-500': form.type === 'expense' }" @click="setType('expense')">지출</button>
|
||||
</div>
|
||||
|
||||
<div class="divide-y divide-line border-y border-line">
|
||||
<div class="grid grid-cols-[4.25rem_1fr] items-center gap-3 py-3">
|
||||
<div class="bg-white">
|
||||
<div class="transaction-form-row">
|
||||
<span class="text-sm font-semibold text-muted">날짜</span>
|
||||
<BottomSheetDatePicker v-model="form.occurredAt" />
|
||||
<BottomSheetDatePicker v-model="form.occurredAt" button-class="transaction-form-select" />
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-[4.25rem_1fr] items-center gap-3 py-3">
|
||||
<div class="transaction-form-row" :class="{ 'transaction-form-row-error': invalidField === 'assetId' }">
|
||||
<span class="text-sm font-semibold text-muted">자산</span>
|
||||
<BottomSheetSelect
|
||||
ref="assetSelectRef"
|
||||
v-model="form.assetId"
|
||||
title="자산 선택"
|
||||
placeholder=""
|
||||
button-class="transaction-form-select"
|
||||
:options="assetOptions"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-[4.25rem_1fr] items-center gap-3 py-3">
|
||||
<div class="transaction-form-row" :class="{ 'transaction-form-row-error': invalidField === 'categoryId' }">
|
||||
<span class="text-sm font-semibold text-muted">분류</span>
|
||||
<BottomSheetSelect
|
||||
ref="categorySelectRef"
|
||||
v-model="form.categoryId"
|
||||
title="분류 선택"
|
||||
placeholder=""
|
||||
button-class="transaction-form-select"
|
||||
:options="categoryOptions"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-[4.25rem_1fr] items-center gap-3 py-3">
|
||||
<div class="transaction-form-row" :class="{ 'transaction-form-row-error': invalidField === 'amount' }">
|
||||
<label class="text-sm font-semibold text-muted" for="amount">금액</label>
|
||||
<input id="amount" v-model.number="form.amount" class="field border-0 bg-transparent text-right text-lg font-bold shadow-none focus:ring-0" inputmode="numeric" min="1" type="number" required>
|
||||
<input
|
||||
id="amount"
|
||||
ref="amountInputRef"
|
||||
v-model.number="form.amount"
|
||||
class="transaction-form-control text-right text-lg font-bold"
|
||||
inputmode="numeric"
|
||||
min="1"
|
||||
type="number"
|
||||
@focus="invalidField = ''"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-[4.25rem_1fr] items-center gap-3 py-3">
|
||||
<div class="transaction-form-row" :class="{ 'transaction-form-row-error': invalidField === 'title' }">
|
||||
<label class="text-sm font-semibold text-muted" for="title">내용</label>
|
||||
<input id="title" v-model="form.title" class="field border-0 bg-transparent font-semibold shadow-none focus:ring-0" required>
|
||||
<input
|
||||
id="title"
|
||||
ref="titleInputRef"
|
||||
v-model="form.title"
|
||||
class="transaction-form-control"
|
||||
@focus="invalidField = ''"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-[4.25rem_1fr] items-start gap-3 py-3">
|
||||
<div class="transaction-form-row items-start">
|
||||
<label class="pt-2 text-sm font-semibold text-muted" for="memo">메모</label>
|
||||
<textarea id="memo" v-model="form.memo" class="field min-h-12 resize-none border-0 bg-transparent shadow-none focus:ring-0" maxlength="60"></textarea>
|
||||
<textarea id="memo" v-model="form.memo" class="transaction-form-control min-h-12 resize-none" maxlength="60"></textarea>
|
||||
</div>
|
||||
|
||||
<div v-if="locationLabel" class="grid grid-cols-[4.25rem_1fr] items-center gap-3 py-3">
|
||||
<div v-if="locationLabel" class="transaction-form-row">
|
||||
<span class="text-sm font-semibold text-muted">위치</span>
|
||||
<div class="flex items-center justify-end gap-3">
|
||||
<a class="text-sm font-semibold text-brand-700" :href="mapsUrl" target="_blank" rel="noreferrer">보기</a>
|
||||
@@ -79,10 +96,21 @@
|
||||
<button type="button" class="btn-secondary gap-2" disabled>자주사용</button>
|
||||
</div>
|
||||
|
||||
<button class="btn-primary mt-6 w-full" type="submit" :disabled="pending || !canSubmit">
|
||||
<div class="mt-6 grid grid-cols-[2fr_1fr] gap-2">
|
||||
<button class="btn-primary min-h-12 text-base" type="submit" :disabled="pending">
|
||||
{{ pending ? '저장 중...' : '저장하기' }}
|
||||
</button>
|
||||
<button class="btn-secondary min-h-12 border-slate-300 text-base" type="button" @click="goBack">
|
||||
취소
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<Transition enter-active-class="transition duration-150 ease-out" enter-from-class="translate-y-2 opacity-0" enter-to-class="translate-y-0 opacity-100" leave-active-class="transition duration-150 ease-in" leave-from-class="translate-y-0 opacity-100" leave-to-class="translate-y-2 opacity-0">
|
||||
<div v-if="toastMessage" class="fixed inset-x-4 bottom-24 z-50 mx-auto max-w-sm rounded-md bg-ink px-4 py-3 text-center text-sm font-semibold text-white shadow-soft">
|
||||
{{ toastMessage }}
|
||||
</div>
|
||||
</Transition>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -93,6 +121,13 @@ const route = useRoute()
|
||||
const router = useRouter()
|
||||
const id = computed(() => String(route.params.id))
|
||||
const pending = ref(false)
|
||||
const invalidField = ref('')
|
||||
const toastMessage = ref('')
|
||||
let toastTimer: ReturnType<typeof setTimeout> | null = null
|
||||
const assetSelectRef = ref<{ focus: () => void } | null>(null)
|
||||
const categorySelectRef = ref<{ focus: () => void } | null>(null)
|
||||
const amountInputRef = ref<HTMLInputElement | null>(null)
|
||||
const titleInputRef = ref<HTMLInputElement | null>(null)
|
||||
const { data: transaction } = await useFetch<any>(() => `/api/transactions/${id.value}`)
|
||||
const { data: assetsData } = await useFetch('/api/assets')
|
||||
const { data: categoriesData } = await useFetch('/api/categories')
|
||||
@@ -148,12 +183,9 @@ watch(selectableCategories, (items: any[]) => {
|
||||
if (form.categoryId && !items.some(item => item.id === form.categoryId)) form.categoryId = ''
|
||||
})
|
||||
|
||||
const canSubmit = computed(() => {
|
||||
return Boolean(form.assetId && form.categoryId && form.amount && form.amount > 0 && form.title.trim())
|
||||
})
|
||||
|
||||
function setType(type: TransactionType) {
|
||||
form.type = type
|
||||
invalidField.value = ''
|
||||
}
|
||||
|
||||
function clearLocation() {
|
||||
@@ -176,6 +208,7 @@ function payload() {
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!validateForm()) return
|
||||
pending.value = true
|
||||
await $fetch(`/api/transactions/${id.value}`, {
|
||||
method: 'PATCH',
|
||||
@@ -204,4 +237,24 @@ async function copy() {
|
||||
function goBack() {
|
||||
router.back()
|
||||
}
|
||||
|
||||
function validateForm() {
|
||||
if (!form.assetId) return showValidation('assetId', '자산을 선택해 주세요.', () => assetSelectRef.value?.focus())
|
||||
if (!form.categoryId) return showValidation('categoryId', '분류를 선택해 주세요.', () => categorySelectRef.value?.focus())
|
||||
if (!form.amount || form.amount < 1) return showValidation('amount', '금액을 입력해 주세요.', () => amountInputRef.value?.focus())
|
||||
if (!form.title.trim()) return showValidation('title', '내용을 입력해 주세요.', () => titleInputRef.value?.focus())
|
||||
invalidField.value = ''
|
||||
return true
|
||||
}
|
||||
|
||||
function showValidation(field: string, message: string, focus: () => void) {
|
||||
invalidField.value = field
|
||||
toastMessage.value = message
|
||||
if (toastTimer) clearTimeout(toastTimer)
|
||||
toastTimer = setTimeout(() => {
|
||||
toastMessage.value = ''
|
||||
}, 2200)
|
||||
nextTick(() => focus())
|
||||
return false
|
||||
}
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user