Complete transaction favorite actions

This commit is contained in:
2026-08-14 10:24:35 +09:00
parent d6d768ad2c
commit afa1765420
5 changed files with 189 additions and 5 deletions

View File

@@ -0,0 +1,74 @@
type FavoriteTransactionInput = {
type: 'income' | 'expense'
assetId: string
categoryId: string
amount: number
title: string
memo?: string
latitude?: number | null
longitude?: number | null
}
export type FavoriteTransaction = FavoriteTransactionInput & {
id: string
savedAt: string
}
const storageKey = 'money_favorite_transactions'
function readFavorites() {
if (!import.meta.client) return []
try {
const parsed = JSON.parse(localStorage.getItem(storageKey) || '[]')
return Array.isArray(parsed) ? parsed as FavoriteTransaction[] : []
} catch {
return []
}
}
function writeFavorites(items: FavoriteTransaction[]) {
if (!import.meta.client) return
localStorage.setItem(storageKey, JSON.stringify(items.slice(0, 20)))
}
export function useTransactionFavorites() {
const favorites = useState<FavoriteTransaction[]>('transactionFavorites', () => [])
function loadFavorites() {
favorites.value = readFavorites()
}
function saveFavorite(input: FavoriteTransactionInput) {
const title = input.title.trim()
if (!title || !input.assetId || !input.categoryId || !input.amount) return null
const items = readFavorites().filter(item => !(
item.type === input.type &&
item.assetId === input.assetId &&
item.categoryId === input.categoryId &&
item.amount === input.amount &&
item.title === title
))
const favorite: FavoriteTransaction = {
...input,
title,
memo: input.memo?.trim().slice(0, 60) || '',
id: globalThis.crypto?.randomUUID?.() || `${Date.now()}-${Math.random().toString(36).slice(2)}`,
savedAt: new Date().toISOString()
}
writeFavorites([favorite, ...items])
loadFavorites()
return favorite
}
function removeFavorite(id: string) {
writeFavorites(readFavorites().filter(item => item.id !== id))
loadFavorites()
}
return {
favorites,
loadFavorites,
saveFavorite,
removeFavorite
}
}

View File

@@ -71,3 +71,5 @@
- 거래 입력/수정의 금액 입력을 기본 숫자 input에서 숫자만 정리하는 텍스트 입력으로 바꾸고 천 단위 표시를 적용했다.
- 통계 화면의 카테고리 집계를 최종 카테고리 기준으로 바꾸고 목록 높이와 보조 정보를 더 촘촘하게 정리했다.
- 자산 화면을 넓은 화면에서 카드 그리드와 상세 2열 구조로 보이도록 조정했다.
- 거래 상세 화면의 자주사용 버튼을 실제 템플릿 저장 기능과 연결하고, 거래 추가 화면에서 자주사용 내역을 바텀시트로 불러올 수 있도록 했다.
- 거래 삭제 확인을 브라우저 기본 confirm 대신 앱 스타일의 바텀시트로 변경했다.

View File

@@ -12,7 +12,15 @@
</button>
<h1 class="text-lg font-bold">{{ form.type === 'income' ? '수입' : '지출' }}</h1>
</div>
<div class="h-9 w-9" aria-hidden="true" />
<button
type="button"
class="flex h-9 w-9 items-center justify-center rounded-md text-xl leading-none active:bg-paper"
:class="favorites.length ? 'text-ink' : 'text-muted'"
aria-label="자주사용"
@click="openFavoriteSheet"
>
☆
</button>
</header>
<form class="bg-white px-4 py-4" @submit.prevent="submit">
@@ -111,16 +119,51 @@
{{ toastMessage }}
</div>
</Transition>
<Teleport to="body">
<Transition enter-active-class="transition duration-200 ease-out" enter-from-class="opacity-0" enter-to-class="opacity-100" leave-active-class="transition duration-150 ease-in" leave-from-class="opacity-100" leave-to-class="opacity-0">
<div v-if="favoriteSheetOpen" class="fixed inset-0 z-40 bg-ink/35" @click="favoriteSheetOpen = false" />
</Transition>
<Transition enter-active-class="transition duration-200 ease-out" enter-from-class="translate-y-full" enter-to-class="translate-y-0" leave-active-class="transition duration-150 ease-in" leave-from-class="translate-y-0" leave-to-class="translate-y-full">
<section v-if="favoriteSheetOpen" class="fixed inset-x-0 bottom-0 z-50 mx-auto max-h-[78vh] max-w-lg overflow-hidden rounded-t-2xl bg-white shadow-2xl">
<div class="sticky top-0 border-b border-line bg-white px-4 pb-3 pt-2">
<div class="mx-auto mb-3 h-1 w-10 rounded-full bg-line" />
<h2 class="text-base font-bold">자주사용</h2>
</div>
<div class="max-h-[58vh] overflow-y-auto px-2 py-2">
<div
v-for="favorite in favorites"
:key="favorite.id"
class="flex w-full items-center justify-between gap-3 rounded-md px-3 py-3 text-left active:bg-paper"
>
<button type="button" class="min-w-0 flex-1 text-left" @click="applyFavorite(favorite)">
<span class="block truncate text-sm font-semibold text-ink">{{ favorite.title }}</span>
<span class="mt-1 block truncate text-xs text-muted">
{{ favorite.type === 'income' ? '수입' : '지출' }} · {{ formatWon(favorite.amount) }}
</span>
</button>
<button type="button" class="shrink-0 rounded-md px-2 py-1 text-xs font-semibold text-muted active:bg-paper" @click="deleteFavorite(favorite.id)">삭제</button>
</div>
<p v-if="!favorites.length" class="px-4 py-12 text-center text-sm text-muted">
저장된 자주사용 내역이 없습니다.
</p>
</div>
</section>
</Transition>
</Teleport>
</section>
</template>
<script setup lang="ts">
import type { FavoriteTransaction } from '~/composables/useTransactionFavorites'
type TransactionType = 'income' | 'expense'
const router = useRouter()
const pending = ref(false)
const invalidField = ref('')
const toastMessage = ref('')
const favoriteSheetOpen = ref(false)
let toastTimer: ReturnType<typeof setTimeout> | null = null
const assetSelectRef = ref<{ focus: () => void } | null>(null)
const categorySelectRef = ref<{ focus: () => void } | null>(null)
@@ -128,6 +171,7 @@ 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')
const { favorites, loadFavorites, removeFavorite } = useTransactionFavorites()
const form = reactive({
type: 'expense' as TransactionType,
@@ -164,6 +208,10 @@ watch(selectableCategories, (items: any[]) => {
if (form.categoryId && !items.some(item => item.id === form.categoryId)) form.categoryId = ''
}, { immediate: true })
onMounted(() => {
loadFavorites()
})
function setType(type: TransactionType) {
if (form.type === type) return
form.type = type
@@ -197,6 +245,31 @@ function clearLocation() {
form.longitude = null
}
function openFavoriteSheet() {
loadFavorites()
favoriteSheetOpen.value = true
}
function applyFavorite(favorite: FavoriteTransaction) {
Object.assign(form, {
type: favorite.type,
assetId: favorite.assetId,
categoryId: favorite.categoryId,
amount: favorite.amount,
title: favorite.title,
memo: favorite.memo || '',
latitude: favorite.latitude ?? null,
longitude: favorite.longitude ?? null
})
invalidField.value = ''
favoriteSheetOpen.value = false
showToast('자주사용 내역을 불러왔습니다.')
}
function deleteFavorite(id: string) {
removeFavorite(id)
}
function updateAmount(event: Event) {
const raw = (event.target as HTMLInputElement).value.replace(/[^\d]/g, '')
form.amount = raw ? Number(raw) : null

View File

@@ -94,9 +94,9 @@
</div>
<div class="mt-6 grid grid-cols-3 gap-2">
<button type="button" class="btn-secondary gap-2" @click="remove">삭제</button>
<button type="button" class="btn-secondary gap-2" @click="deleteSheetOpen = true">삭제</button>
<button type="button" class="btn-secondary gap-2" @click="copy">복사</button>
<button type="button" class="btn-secondary gap-2" disabled>자주사용</button>
<button type="button" class="btn-secondary gap-2" @click="saveAsFavorite">자주사용</button>
</div>
<div class="mt-6 grid grid-cols-[2fr_1fr] gap-2">
@@ -114,6 +114,25 @@
{{ toastMessage }}
</div>
</Transition>
<Teleport to="body">
<Transition enter-active-class="transition duration-200 ease-out" enter-from-class="opacity-0" enter-to-class="opacity-100" leave-active-class="transition duration-150 ease-in" leave-from-class="opacity-100" leave-to-class="opacity-0">
<div v-if="deleteSheetOpen" class="fixed inset-0 z-40 bg-ink/35" @click="deleteSheetOpen = false" />
</Transition>
<Transition enter-active-class="transition duration-200 ease-out" enter-from-class="translate-y-full" enter-to-class="translate-y-0" leave-active-class="transition duration-150 ease-in" leave-from-class="translate-y-0" leave-to-class="translate-y-full">
<section v-if="deleteSheetOpen" class="fixed inset-x-0 bottom-0 z-50 mx-auto max-w-lg rounded-t-2xl bg-white px-4 pb-[calc(env(safe-area-inset-bottom)+16px)] pt-2 shadow-2xl">
<div class="mx-auto mb-4 h-1 w-10 rounded-full bg-line" />
<h2 class="text-base font-bold text-ink">거래를 삭제할까요?</h2>
<p class="mt-2 text-sm text-muted">삭제한 거래는 다시 되돌릴 수 없습니다.</p>
<div class="mt-5 grid grid-cols-[1fr_2fr] gap-2">
<button type="button" class="btn-secondary min-h-12 text-base" @click="deleteSheetOpen = false">취소</button>
<button type="button" class="btn-primary min-h-12 bg-red-500 text-base hover:bg-red-500" :disabled="pending" @click="remove">
{{ pending ? '삭제 중...' : '삭제하기' }}
</button>
</div>
</section>
</Transition>
</Teleport>
</section>
</template>
@@ -126,6 +145,7 @@ const id = computed(() => String(route.params.id))
const pending = ref(false)
const invalidField = ref('')
const toastMessage = ref('')
const deleteSheetOpen = ref(false)
let toastTimer: ReturnType<typeof setTimeout> | null = null
const assetSelectRef = ref<{ focus: () => void } | null>(null)
const categorySelectRef = ref<{ focus: () => void } | null>(null)
@@ -134,6 +154,7 @@ 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')
const { saveFavorite } = useTransactionFavorites()
const form = reactive({
type: 'expense' as TransactionType,
@@ -248,7 +269,7 @@ async function save() {
}
async function remove() {
if (!confirm('이 거래를 삭제할까요?')) return
deleteSheetOpen.value = false
pending.value = true
try {
await $fetch(`/api/transactions/${id.value}`, { method: 'DELETE' })
@@ -260,6 +281,21 @@ async function remove() {
}
}
function saveAsFavorite() {
if (!validateForm()) return
const saved = saveFavorite({
type: form.type,
assetId: form.assetId,
categoryId: form.categoryId,
amount: form.amount,
title: form.title,
memo: form.memo,
latitude: form.latitude,
longitude: form.longitude
})
showToast(saved ? '자주사용에 저장했습니다.' : '자주사용에 저장하지 못했습니다.')
}
async function copy() {
if (!validateForm()) return
pending.value = true

View File

@@ -17,7 +17,6 @@
## 3. 거래 입력/수정
- 입력 화면의 모바일 키보드 노출 상태에서 저장/취소 버튼 위치와 스크롤 동작을 점검한다.
- 복사, 삭제, 자주사용 버튼의 실제 동작을 완성한다.
- 사진 첨부 UI의 저장 구조를 설계하고 구현한다.
## 4. 카테고리