Refine add transaction form
This commit is contained in:
@@ -33,3 +33,4 @@
|
||||
- 내역 검색과 유형/자산/카테고리 필터 바텀 시트를 구현했다.
|
||||
- 거래 목록 API에 검색어, 유형, 자산, 카테고리 필터 query를 추가했다.
|
||||
- 월 선택 바텀 시트를 공통 컴포넌트로 분리하고 내역, 통계, 자산 화면에 적용했다.
|
||||
- 거래 추가 화면을 거래 수정 화면과 같은 행 기반 입력 구조로 정리했다.
|
||||
|
||||
158
pages/add.vue
158
pages/add.vue
@@ -1,69 +1,80 @@
|
||||
<template>
|
||||
<section class="px-4 py-4">
|
||||
<h1 class="mb-4 text-lg font-bold">거래 추가</h1>
|
||||
<section class="min-h-dvh bg-paper">
|
||||
<header class="sticky top-0 z-10 flex h-14 items-center justify-between border-b border-line bg-white px-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-9 w-9 items-center justify-center rounded-md active:bg-paper"
|
||||
aria-label="뒤로"
|
||||
@click="goBack"
|
||||
>
|
||||
<img class="h-6 w-6 opacity-70" src="~/assets/icons/keyboard_arrow_left.svg" alt="">
|
||||
</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>
|
||||
</header>
|
||||
|
||||
<form class="space-y-3" @submit.prevent="submit">
|
||||
<div class="segmented">
|
||||
<button type="button" class="segmented-button" :class="{ 'segmented-button-active': form.type === 'income' }" @click="form.type = 'income'">수입</button>
|
||||
<button type="button" class="segmented-button" :class="{ 'segmented-button-active': form.type === 'expense' }" @click="form.type = 'expense'">지출</button>
|
||||
<form class="bg-white px-4 py-4" @submit.prevent="submit">
|
||||
<div class="mb-5 grid grid-cols-2 gap-2">
|
||||
<button type="button" class="segmented-button border border-line py-2.5" :class="{ 'border-brand-700 text-brand-700': form.type === 'income' }" @click="setType('income')">수입</button>
|
||||
<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="grid grid-cols-[1fr_auto] gap-2">
|
||||
<div>
|
||||
<label class="label">날짜</label>
|
||||
<div class="divide-y divide-line border-y border-line">
|
||||
<div class="grid grid-cols-[4.25rem_1fr] items-center gap-3 py-3">
|
||||
<span class="text-sm font-semibold text-muted">날짜</span>
|
||||
<BottomSheetDatePicker v-model="form.occurredAt" />
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">반복</span>
|
||||
<button type="button" class="btn-secondary h-[42px]" disabled>없음</button>
|
||||
|
||||
<div class="grid grid-cols-[4.25rem_1fr] items-center gap-3 py-3">
|
||||
<span class="text-sm font-semibold text-muted">자산</span>
|
||||
<BottomSheetSelect
|
||||
v-model="form.assetId"
|
||||
title="자산 선택"
|
||||
placeholder="자산 선택"
|
||||
:options="assetOptions"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-[4.25rem_1fr] items-center gap-3 py-3">
|
||||
<span class="text-sm font-semibold text-muted">분류</span>
|
||||
<BottomSheetSelect
|
||||
v-model="form.categoryId"
|
||||
title="분류 선택"
|
||||
placeholder="분류 선택"
|
||||
:options="categoryOptions"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-[4.25rem_1fr] items-center gap-3 py-3">
|
||||
<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" placeholder="0" type="number" required>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-[4.25rem_1fr] items-center gap-3 py-3">
|
||||
<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" placeholder="이마트칠성점" required>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="label">자산</label>
|
||||
<BottomSheetSelect
|
||||
v-model="form.assetId"
|
||||
title="자산 선택"
|
||||
placeholder="자산 선택"
|
||||
:options="assetOptions"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="label">분류</label>
|
||||
<BottomSheetSelect
|
||||
v-model="form.categoryId"
|
||||
title="분류 선택"
|
||||
placeholder="분류 선택"
|
||||
:options="categoryOptions"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="label" for="amount">금액</label>
|
||||
<input id="amount" v-model.number="form.amount" class="field text-right text-lg font-bold" inputmode="numeric" min="1" placeholder="0" type="number" required>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="label" for="title">내용</label>
|
||||
<input id="title" v-model="form.title" class="field" placeholder="이마트칠성점" required>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="mt-4 border-y border-line py-4">
|
||||
<div class="mb-1 flex items-center justify-between">
|
||||
<span class="label mb-0">위치</span>
|
||||
<span class="text-sm font-semibold text-muted">위치</span>
|
||||
<div class="flex gap-2">
|
||||
<button type="button" class="text-sm font-semibold text-brand-700" @click="captureLocation">가져오기</button>
|
||||
<button v-if="form.latitude" type="button" class="text-sm font-semibold text-muted" @click="clearLocation">삭제</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="rounded-md border border-line bg-paper px-4 py-2.5 text-sm text-muted">
|
||||
<p class="mt-2 rounded-md bg-paper px-4 py-2.5 text-sm text-muted">
|
||||
{{ form.latitude ? `${form.latitude}, ${form.longitude}` : '선택 사항입니다.' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="label" for="memo">메모</label>
|
||||
<div class="mt-4 border-y border-line py-4">
|
||||
<label class="mb-2 block text-sm font-semibold text-muted" for="memo">메모</label>
|
||||
<textarea id="memo" v-model="form.memo" class="field min-h-24 resize-none" maxlength="60" placeholder="자유롭게 기록"></textarea>
|
||||
</div>
|
||||
|
||||
@@ -75,12 +86,15 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
type TransactionType = 'income' | 'expense'
|
||||
|
||||
const router = useRouter()
|
||||
const pending = ref(false)
|
||||
const { data: assetsData } = await useFetch('/api/assets')
|
||||
const { data: categoriesData } = await useFetch('/api/categories')
|
||||
|
||||
const form = reactive({
|
||||
type: 'expense',
|
||||
type: 'expense' as TransactionType,
|
||||
occurredAt: formatDateOnly(),
|
||||
assetId: '',
|
||||
categoryId: '',
|
||||
@@ -113,6 +127,10 @@ watch(filteredCategories, (items: any[]) => {
|
||||
if (!items.some(item => item.id === form.categoryId)) form.categoryId = items[0]?.id || ''
|
||||
}, { immediate: true })
|
||||
|
||||
function setType(type: TransactionType) {
|
||||
form.type = type
|
||||
}
|
||||
|
||||
function captureLocation() {
|
||||
if (!navigator.geolocation) return
|
||||
navigator.geolocation.getCurrentPosition((position) => {
|
||||
@@ -127,24 +145,32 @@ function clearLocation() {
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (pending.value) return
|
||||
pending.value = true
|
||||
await $fetch('/api/transactions', {
|
||||
method: 'POST',
|
||||
body: { ...form, occurredAt: dateOnlyToNoon(form.occurredAt) }
|
||||
})
|
||||
const keepAssetId = form.assetId
|
||||
Object.assign(form, {
|
||||
type: form.type,
|
||||
occurredAt: formatDateOnly(),
|
||||
assetId: keepAssetId,
|
||||
categoryId: form.categoryId,
|
||||
amount: null,
|
||||
title: '',
|
||||
memo: '',
|
||||
latitude: null,
|
||||
longitude: null
|
||||
})
|
||||
pending.value = false
|
||||
await navigateTo('/')
|
||||
try {
|
||||
await $fetch('/api/transactions', {
|
||||
method: 'POST',
|
||||
body: { ...form, occurredAt: dateOnlyToNoon(form.occurredAt) }
|
||||
})
|
||||
const keepAssetId = form.assetId
|
||||
Object.assign(form, {
|
||||
type: form.type,
|
||||
occurredAt: formatDateOnly(),
|
||||
assetId: keepAssetId,
|
||||
categoryId: form.categoryId,
|
||||
amount: null,
|
||||
title: '',
|
||||
memo: '',
|
||||
latitude: null,
|
||||
longitude: null
|
||||
})
|
||||
await navigateTo('/')
|
||||
} finally {
|
||||
pending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
router.back()
|
||||
}
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user