Files

339 lines
13 KiB
Vue

<template>
<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="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">
<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="bg-white">
<div class="transaction-form-row">
<span class="text-sm font-semibold text-muted">날짜</span>
<BottomSheetDatePicker v-model="form.occurredAt" button-class="transaction-form-select" />
</div>
<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"
@update:model-value="handleAssetSelected"
/>
</div>
<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"
@update:model-value="invalidField = ''"
/>
</div>
<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"
ref="amountInputRef"
:value="amountDisplay"
class="transaction-form-control text-right text-lg font-bold"
inputmode="numeric"
min="1"
type="text"
@focus="invalidField = ''"
@input="updateAmount"
>
</div>
<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"
ref="titleInputRef"
v-model="form.title"
class="transaction-form-control"
@focus="invalidField = ''"
>
</div>
<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="transaction-form-control min-h-12 resize-none" maxlength="60"></textarea>
</div>
<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">
{{ form.latitude }}, {{ form.longitude }}
</span>
<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>
</div>
<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>
<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)
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,
occurredAt: formatDateOnly(),
assetId: '',
categoryId: '',
amount: null as number | null,
title: '',
memo: '',
latitude: null as number | null,
longitude: null as number | null
})
const assets = computed(() => assetsData.value || [])
const categories = computed(() => categoriesData.value || [])
const filteredCategories = computed(() => categories.value.filter((category: any) => category.type === form.type && category.active))
const assetOptions = computed(() => assets.value.map((asset: any) => ({
value: asset.id,
label: asset.name,
description: asset.balanceType === 'cumulative' ? '계속 누적' : '매월 새로 시작'
})))
const selectableCategories = computed(() => {
const activeIdsWithChildren = new Set(filteredCategories.value.filter((category: any) => category.parentId).map((category: any) => category.parentId))
return filteredCategories.value.filter((category: any) => !activeIdsWithChildren.has(category.id))
})
const categoryOptions = computed(() => selectableCategories.value.map((category: any) => ({
value: category.id,
label: category.name,
description: category.parent?.name || (category.type === 'income' ? '수입' : '지출')
})))
const amountDisplay = computed(() => form.amount ? form.amount.toLocaleString('ko-KR') : '')
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
form.categoryId = ''
invalidField.value = ''
if (form.assetId) openCategorySelect()
}
function handleAssetSelected() {
invalidField.value = ''
if (!form.categoryId) openCategorySelect()
}
function openCategorySelect() {
if (!categoryOptions.value.length) return
window.setTimeout(() => {
categorySelectRef.value?.focus()
}, 220)
}
function captureLocation() {
if (!navigator.geolocation) return
navigator.geolocation.getCurrentPosition((position) => {
form.latitude = Number(position.coords.latitude.toFixed(7))
form.longitude = Number(position.coords.longitude.toFixed(7))
})
}
function clearLocation() {
form.latitude = 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) {
const raw = (event.target as HTMLInputElement).value.replace(/[^\d]/g, '')
form.amount = raw ? Number(raw) : null
}
async function submit() {
if (pending.value) return
if (!validateForm()) return
pending.value = true
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('/')
} catch {
showToast('거래를 저장하지 못했습니다.')
} finally {
pending.value = false
}
}
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
}
function showToast(message: string) {
toastMessage.value = message
if (toastTimer) clearTimeout(toastTimer)
toastTimer = setTimeout(() => {
toastMessage.value = ''
}, 2200)
}
</script>