Complete transaction favorite actions
This commit is contained in:
74
composables/useTransactionFavorites.ts
Normal file
74
composables/useTransactionFavorites.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -71,3 +71,5 @@
|
||||
- 거래 입력/수정의 금액 입력을 기본 숫자 input에서 숫자만 정리하는 텍스트 입력으로 바꾸고 천 단위 표시를 적용했다.
|
||||
- 통계 화면의 카테고리 집계를 최종 카테고리 기준으로 바꾸고 목록 높이와 보조 정보를 더 촘촘하게 정리했다.
|
||||
- 자산 화면을 넓은 화면에서 카드 그리드와 상세 2열 구조로 보이도록 조정했다.
|
||||
- 거래 상세 화면의 자주사용 버튼을 실제 템플릿 저장 기능과 연결하고, 거래 추가 화면에서 자주사용 내역을 바텀시트로 불러올 수 있도록 했다.
|
||||
- 거래 삭제 확인을 브라우저 기본 confirm 대신 앱 스타일의 바텀시트로 변경했다.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user