Refine transaction form validation UI

This commit is contained in:
2026-08-13 18:28:37 +09:00
parent 779b86b748
commit 7888a06d8f
8 changed files with 193 additions and 48 deletions

View File

@@ -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;
}
}

View File

@@ -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())

View File

@@ -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>

View File

@@ -22,7 +22,7 @@
- 거래 목록 행을 날짜 그룹, 카테고리 아이콘 자리, 기본 2줄 정보, 메모가 있을 때만 보이는 3번째 줄 구조로 변경했다.
- 메모 입력과 저장 길이를 최대 60자로 제한했다.
- 거래 입력/수정에서 시간 입력을 제거하고 날짜 바텀 시트 선택으로 변경했다.
- 금액 입력 기본값을 제거하고 placeholder로 안내하도록 변경했다.
- 금액 입력 기본값을 제거했다.
- 거래 목록의 카테고리 표시는 하위 카테고리명 중심으로 단순화했다.
- 내역 화면 상단을 헤더, 월 요약, 주간 날짜 스트립 구조로 개편했다.
- 방향 이동 버튼을 `assets/icons`의 SVG 화살표로 통일했다.
@@ -50,3 +50,6 @@
- 설정의 계정 관리 화면에서 이름 수정과 비밀번호 변경 기능을 구현했다.
- 내역 화면의 주간 날짜 스트립에 좌우 슬라이드 전환 애니메이션을 추가했다.
- 거래 입력/수정 화면의 자산, 분류, 금액, 내용 placeholder를 제거하고 메모/위치 영역을 같은 입력 리스트 안의 얇은 행으로 정리했다.
- 거래 입력/수정 화면의 상단 저장 버튼을 제거하고 하단 저장/취소 2:1 버튼 구조로 변경했다.
- 거래 입력/수정 화면에 필수값 누락 토스트와 해당 입력 줄 포커스/강조 피드백을 추가했다.
- 거래 입력/수정 행 스타일을 박스형 입력에서 밑줄형 입력으로 변경했다.

View File

@@ -18,6 +18,9 @@
- 날짜 선택은 모바일 친화적인 바텀 시트 캘린더를 사용한다.
- 자산, 분류, 금액, 내용 입력은 placeholder 문구를 넣지 않고 비어 있는 입력 상태로 둔다.
- 금액 입력은 기본값 `0`을 채우지 않는다.
- 거래 입력/수정 저장 버튼은 하단에만 두고, `저장하기`와 `취소`를 2:1 비율로 배치한다.
- 필수값 없이 저장을 누르면 토스트로 부족한 항목을 알려주고 해당 입력 줄에 포커스와 강조선을 준다.
- 거래 입력 행은 위아래 박스 라인 대신 각 입력란 하단 밑줄만 사용하고, 포커스 중인 줄만 포인트 색상으로 강조한다.
- 셀렉트 박스보다 모바일 친화적인 바텀 시트를 우선한다.
- 활성/비활성 같은 상태는 텍스트 버튼보다 토글처럼 현재 상태가 보이는 UI를 쓴다.
- 통계 화면은 그래프보다 숫자와 분류별 리스트를 우선한다.

View File

@@ -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">
{{ pending ? '저장 중...' : '저장하기' }}
</button>
<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>

View File

@@ -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">
{{ pending ? '저장 중...' : '저장하기' }}
</button>
<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>

View File

@@ -21,6 +21,7 @@
- 날짜 선택 바텀 시트의 오늘/이번 달 빠른 이동 기능을 추가한다.
- 자산 선택 후 카테고리 선택으로 자연스럽게 이어지는 입력 흐름을 만든다.
- 금액 입력 키패드 또는 숫자 입력 UX를 개선한다.
- 입력 화면의 모바일 키보드 노출 상태에서 저장/취소 버튼 위치와 스크롤 동작을 점검한다.
- 복사, 삭제, 자주사용 버튼의 실제 동작을 완성한다.
- 사진 첨부 UI의 저장 구조를 설계하고 구현한다.