Files
money.sori.studio/pages/add.vue

181 lines
6.9 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="text-sm font-bold text-brand-700" :disabled="pending" @click="submit">
저장
</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="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 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 class="mt-4 border-y border-line py-4">
<div class="mb-1 flex items-center justify-between">
<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="mt-2 rounded-md bg-paper px-4 py-2.5 text-sm text-muted">
{{ form.latitude ? `${form.latitude}, ${form.longitude}` : '선택 사항입니다.' }}
</p>
</div>
<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>
<button class="btn-primary mt-5 w-full" type="submit" :disabled="pending">
{{ pending ? '저장 중...' : '저장하기' }}
</button>
</form>
</section>
</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' 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' ? '수입' : '지출')
})))
watch(assets, (items: any[]) => {
if (!form.assetId && items[0]) form.assetId = items[0].id
}, { immediate: true })
watch(selectableCategories, (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) => {
form.latitude = Number(position.coords.latitude.toFixed(7))
form.longitude = Number(position.coords.longitude.toFixed(7))
})
}
function clearLocation() {
form.latitude = null
form.longitude = null
}
async function submit() {
if (pending.value) 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('/')
} finally {
pending.value = false
}
}
function goBack() {
router.back()
}
</script>