197 lines
7.3 KiB
Vue
197 lines
7.3 KiB
Vue
<template>
|
||
<section class="min-h-screen bg-paper">
|
||
<header class="sticky top-0 z-10 flex h-16 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-10 w-10 items-center justify-center rounded-md text-3xl leading-none active:bg-paper"
|
||
aria-label="뒤로"
|
||
@click="goBack"
|
||
>
|
||
‹
|
||
</button>
|
||
<h1 class="text-xl font-bold">{{ form.type === 'income' ? '수입' : '지출' }}</h1>
|
||
</div>
|
||
<button type="button" class="text-sm font-bold text-brand-700" :disabled="pending" @click="save">
|
||
저장
|
||
</button>
|
||
</header>
|
||
|
||
<form class="bg-white px-4 py-5" @submit.prevent="save">
|
||
<div class="mb-5 grid grid-cols-2 gap-2">
|
||
<button type="button" class="segmented-button border border-line py-3" :class="{ 'border-brand-700 text-brand-700': form.type === 'income' }" @click="setType('income')">수입</button>
|
||
<button type="button" class="segmented-button border border-line py-3" :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-[5rem_1fr] items-center gap-3 py-4">
|
||
<label class="text-lg font-semibold text-muted" for="occurredAt">날짜</label>
|
||
<input id="occurredAt" v-model="form.occurredAt" class="w-full bg-transparent py-2 text-lg font-semibold outline-none" type="datetime-local" required>
|
||
</div>
|
||
|
||
<div class="grid grid-cols-[5rem_1fr] items-center gap-3 py-4">
|
||
<span class="text-lg font-semibold text-muted">자산</span>
|
||
<BottomSheetSelect
|
||
v-model="form.assetId"
|
||
title="자산 선택"
|
||
placeholder="자산 선택"
|
||
:options="assetOptions"
|
||
/>
|
||
</div>
|
||
|
||
<div class="grid grid-cols-[5rem_1fr] items-center gap-3 py-4">
|
||
<span class="text-lg font-semibold text-muted">분류</span>
|
||
<BottomSheetSelect
|
||
v-model="form.categoryId"
|
||
title="분류 선택"
|
||
placeholder="분류 선택"
|
||
:options="categoryOptions"
|
||
/>
|
||
</div>
|
||
|
||
<div class="grid grid-cols-[5rem_1fr] items-center gap-3 py-4">
|
||
<label class="text-lg font-semibold text-muted" for="amount">금액</label>
|
||
<input id="amount" v-model.number="form.amount" class="w-full bg-transparent py-2 text-right text-2xl font-bold outline-none" inputmode="numeric" min="1" type="number" required>
|
||
</div>
|
||
|
||
<div class="grid grid-cols-[5rem_1fr] items-center gap-3 py-4">
|
||
<label class="text-lg font-semibold text-muted" for="title">내용</label>
|
||
<input id="title" v-model="form.title" class="w-full bg-transparent py-2 text-lg font-semibold outline-none" required>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="mt-4 border-y border-line py-4">
|
||
<label class="mb-2 block text-lg font-semibold text-muted" for="memo">메모</label>
|
||
<textarea id="memo" v-model="form.memo" class="min-h-24 w-full resize-none bg-transparent text-lg outline-none" placeholder="메모"></textarea>
|
||
</div>
|
||
|
||
<div v-if="locationLabel" class="mt-3 flex items-center justify-between border-b border-line py-4">
|
||
<a class="font-semibold text-brand-700" :href="mapsUrl" target="_blank" rel="noreferrer">위치 보기</a>
|
||
<button type="button" class="text-sm font-semibold text-muted" @click="clearLocation">위치 삭제</button>
|
||
</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="copy">복사</button>
|
||
<button type="button" class="btn-secondary gap-2" disabled>자주사용</button>
|
||
</div>
|
||
|
||
<button class="btn-primary mt-6 w-full" type="submit" :disabled="pending">
|
||
{{ pending ? '저장 중...' : '저장하기' }}
|
||
</button>
|
||
</form>
|
||
</section>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
type TransactionType = 'income' | 'expense'
|
||
|
||
const route = useRoute()
|
||
const router = useRouter()
|
||
const id = computed(() => String(route.params.id))
|
||
const pending = ref(false)
|
||
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 form = reactive({
|
||
type: 'expense' as TransactionType,
|
||
occurredAt: formatDateInput(),
|
||
assetId: '',
|
||
categoryId: '',
|
||
amount: 0,
|
||
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 categoryOptions = computed(() => filteredCategories.value.map((category: any) => ({
|
||
value: category.id,
|
||
label: category.parent ? `${category.parent.name} > ${category.name}` : category.name,
|
||
description: category.type === 'income' ? '수입' : '지출'
|
||
})))
|
||
const locationLabel = computed(() => form.latitude && form.longitude)
|
||
const mapsUrl = computed(() => `https://www.google.com/maps?q=${form.latitude},${form.longitude}`)
|
||
|
||
watch(transaction, (value) => {
|
||
if (!value) return
|
||
Object.assign(form, {
|
||
type: value.type,
|
||
occurredAt: formatDateInput(new Date(value.occurredAt)),
|
||
assetId: value.assetId,
|
||
categoryId: value.categoryId,
|
||
amount: value.amount,
|
||
title: value.title,
|
||
memo: value.memo || '',
|
||
latitude: value.latitude ? Number(value.latitude) : null,
|
||
longitude: value.longitude ? Number(value.longitude) : null
|
||
})
|
||
}, { immediate: true })
|
||
|
||
watch(filteredCategories, (items: any[]) => {
|
||
if (!items.some(item => item.id === form.categoryId)) form.categoryId = items[0]?.id || ''
|
||
})
|
||
|
||
function setType(type: TransactionType) {
|
||
form.type = type
|
||
}
|
||
|
||
function clearLocation() {
|
||
form.latitude = null
|
||
form.longitude = null
|
||
}
|
||
|
||
function payload() {
|
||
return {
|
||
type: form.type,
|
||
occurredAt: form.occurredAt,
|
||
assetId: form.assetId,
|
||
categoryId: form.categoryId,
|
||
amount: form.amount,
|
||
title: form.title,
|
||
memo: form.memo,
|
||
latitude: form.latitude,
|
||
longitude: form.longitude
|
||
}
|
||
}
|
||
|
||
async function save() {
|
||
pending.value = true
|
||
await $fetch(`/api/transactions/${id.value}`, {
|
||
method: 'PATCH',
|
||
body: payload()
|
||
})
|
||
pending.value = false
|
||
await router.back()
|
||
}
|
||
|
||
async function remove() {
|
||
if (!confirm('이 거래를 삭제할까요?')) return
|
||
await $fetch(`/api/transactions/${id.value}`, { method: 'DELETE' })
|
||
await navigateTo('/')
|
||
}
|
||
|
||
async function copy() {
|
||
pending.value = true
|
||
const created = await $fetch<any>('/api/transactions', {
|
||
method: 'POST',
|
||
body: { ...payload(), occurredAt: formatDateInput() }
|
||
})
|
||
pending.value = false
|
||
await navigateTo(`/transactions/${created.id}`)
|
||
}
|
||
|
||
function goBack() {
|
||
router.back()
|
||
}
|
||
</script>
|