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에서 숫자만 정리하는 텍스트 입력으로 바꾸고 천 단위 표시를 적용했다.
|
- 거래 입력/수정의 금액 입력을 기본 숫자 input에서 숫자만 정리하는 텍스트 입력으로 바꾸고 천 단위 표시를 적용했다.
|
||||||
- 통계 화면의 카테고리 집계를 최종 카테고리 기준으로 바꾸고 목록 높이와 보조 정보를 더 촘촘하게 정리했다.
|
- 통계 화면의 카테고리 집계를 최종 카테고리 기준으로 바꾸고 목록 높이와 보조 정보를 더 촘촘하게 정리했다.
|
||||||
- 자산 화면을 넓은 화면에서 카드 그리드와 상세 2열 구조로 보이도록 조정했다.
|
- 자산 화면을 넓은 화면에서 카드 그리드와 상세 2열 구조로 보이도록 조정했다.
|
||||||
|
- 거래 상세 화면의 자주사용 버튼을 실제 템플릿 저장 기능과 연결하고, 거래 추가 화면에서 자주사용 내역을 바텀시트로 불러올 수 있도록 했다.
|
||||||
|
- 거래 삭제 확인을 브라우저 기본 confirm 대신 앱 스타일의 바텀시트로 변경했다.
|
||||||
|
|||||||
@@ -12,7 +12,15 @@
|
|||||||
</button>
|
</button>
|
||||||
<h1 class="text-lg font-bold">{{ form.type === 'income' ? '수입' : '지출' }}</h1>
|
<h1 class="text-lg font-bold">{{ form.type === 'income' ? '수입' : '지출' }}</h1>
|
||||||
</div>
|
</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>
|
</header>
|
||||||
|
|
||||||
<form class="bg-white px-4 py-4" @submit.prevent="submit">
|
<form class="bg-white px-4 py-4" @submit.prevent="submit">
|
||||||
@@ -111,16 +119,51 @@
|
|||||||
{{ toastMessage }}
|
{{ toastMessage }}
|
||||||
</div>
|
</div>
|
||||||
</Transition>
|
</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>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import type { FavoriteTransaction } from '~/composables/useTransactionFavorites'
|
||||||
|
|
||||||
type TransactionType = 'income' | 'expense'
|
type TransactionType = 'income' | 'expense'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const pending = ref(false)
|
const pending = ref(false)
|
||||||
const invalidField = ref('')
|
const invalidField = ref('')
|
||||||
const toastMessage = ref('')
|
const toastMessage = ref('')
|
||||||
|
const favoriteSheetOpen = ref(false)
|
||||||
let toastTimer: ReturnType<typeof setTimeout> | null = null
|
let toastTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
const assetSelectRef = ref<{ focus: () => void } | null>(null)
|
const assetSelectRef = ref<{ focus: () => void } | null>(null)
|
||||||
const categorySelectRef = 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 titleInputRef = ref<HTMLInputElement | null>(null)
|
||||||
const { data: assetsData } = await useFetch('/api/assets')
|
const { data: assetsData } = await useFetch('/api/assets')
|
||||||
const { data: categoriesData } = await useFetch('/api/categories')
|
const { data: categoriesData } = await useFetch('/api/categories')
|
||||||
|
const { favorites, loadFavorites, removeFavorite } = useTransactionFavorites()
|
||||||
|
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
type: 'expense' as TransactionType,
|
type: 'expense' as TransactionType,
|
||||||
@@ -164,6 +208,10 @@ watch(selectableCategories, (items: any[]) => {
|
|||||||
if (form.categoryId && !items.some(item => item.id === form.categoryId)) form.categoryId = ''
|
if (form.categoryId && !items.some(item => item.id === form.categoryId)) form.categoryId = ''
|
||||||
}, { immediate: true })
|
}, { immediate: true })
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadFavorites()
|
||||||
|
})
|
||||||
|
|
||||||
function setType(type: TransactionType) {
|
function setType(type: TransactionType) {
|
||||||
if (form.type === type) return
|
if (form.type === type) return
|
||||||
form.type = type
|
form.type = type
|
||||||
@@ -197,6 +245,31 @@ function clearLocation() {
|
|||||||
form.longitude = null
|
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) {
|
function updateAmount(event: Event) {
|
||||||
const raw = (event.target as HTMLInputElement).value.replace(/[^\d]/g, '')
|
const raw = (event.target as HTMLInputElement).value.replace(/[^\d]/g, '')
|
||||||
form.amount = raw ? Number(raw) : null
|
form.amount = raw ? Number(raw) : null
|
||||||
|
|||||||
@@ -94,9 +94,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-6 grid grid-cols-3 gap-2">
|
<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" @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>
|
||||||
|
|
||||||
<div class="mt-6 grid grid-cols-[2fr_1fr] gap-2">
|
<div class="mt-6 grid grid-cols-[2fr_1fr] gap-2">
|
||||||
@@ -114,6 +114,25 @@
|
|||||||
{{ toastMessage }}
|
{{ toastMessage }}
|
||||||
</div>
|
</div>
|
||||||
</Transition>
|
</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>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -126,6 +145,7 @@ const id = computed(() => String(route.params.id))
|
|||||||
const pending = ref(false)
|
const pending = ref(false)
|
||||||
const invalidField = ref('')
|
const invalidField = ref('')
|
||||||
const toastMessage = ref('')
|
const toastMessage = ref('')
|
||||||
|
const deleteSheetOpen = ref(false)
|
||||||
let toastTimer: ReturnType<typeof setTimeout> | null = null
|
let toastTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
const assetSelectRef = ref<{ focus: () => void } | null>(null)
|
const assetSelectRef = ref<{ focus: () => void } | null>(null)
|
||||||
const categorySelectRef = 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: transaction } = await useFetch<any>(() => `/api/transactions/${id.value}`)
|
||||||
const { data: assetsData } = await useFetch('/api/assets')
|
const { data: assetsData } = await useFetch('/api/assets')
|
||||||
const { data: categoriesData } = await useFetch('/api/categories')
|
const { data: categoriesData } = await useFetch('/api/categories')
|
||||||
|
const { saveFavorite } = useTransactionFavorites()
|
||||||
|
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
type: 'expense' as TransactionType,
|
type: 'expense' as TransactionType,
|
||||||
@@ -248,7 +269,7 @@ async function save() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function remove() {
|
async function remove() {
|
||||||
if (!confirm('이 거래를 삭제할까요?')) return
|
deleteSheetOpen.value = false
|
||||||
pending.value = true
|
pending.value = true
|
||||||
try {
|
try {
|
||||||
await $fetch(`/api/transactions/${id.value}`, { method: 'DELETE' })
|
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() {
|
async function copy() {
|
||||||
if (!validateForm()) return
|
if (!validateForm()) return
|
||||||
pending.value = true
|
pending.value = true
|
||||||
|
|||||||
Reference in New Issue
Block a user