347 lines
13 KiB
Vue
347 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>
|
|
<div class="h-9 w-9" aria-hidden="true" />
|
|
</header>
|
|
|
|
<form class="bg-white px-4 py-4" @submit.prevent="save">
|
|
<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 v-if="locationLabel" class="transaction-form-row">
|
|
<span class="text-sm font-semibold text-muted">위치</span>
|
|
<div class="flex items-center justify-end gap-3">
|
|
<a class="text-sm 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>
|
|
</div>
|
|
|
|
<div class="mt-6 grid grid-cols-3 gap-2">
|
|
<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="saveAsFavorite">자주사용</button>
|
|
</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="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>
|
|
|
|
<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 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)
|
|
const amountInputRef = ref<HTMLInputElement | null>(null)
|
|
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,
|
|
occurredAt: formatDateOnly(),
|
|
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 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') : '')
|
|
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: formatDateOnly(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(selectableCategories, (items: any[]) => {
|
|
if (form.categoryId && !items.some(item => item.id === form.categoryId)) form.categoryId = ''
|
|
})
|
|
|
|
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 clearLocation() {
|
|
form.latitude = null
|
|
form.longitude = null
|
|
}
|
|
|
|
function updateAmount(event: Event) {
|
|
const raw = (event.target as HTMLInputElement).value.replace(/[^\d]/g, '')
|
|
form.amount = raw ? Number(raw) : 0
|
|
}
|
|
|
|
function payload() {
|
|
return {
|
|
type: form.type,
|
|
occurredAt: dateOnlyToNoon(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() {
|
|
if (!validateForm()) return
|
|
pending.value = true
|
|
try {
|
|
await $fetch(`/api/transactions/${id.value}`, {
|
|
method: 'PATCH',
|
|
body: payload()
|
|
})
|
|
await router.back()
|
|
} catch {
|
|
showToast('거래를 저장하지 못했습니다.')
|
|
} finally {
|
|
pending.value = false
|
|
}
|
|
}
|
|
|
|
async function remove() {
|
|
deleteSheetOpen.value = false
|
|
pending.value = true
|
|
try {
|
|
await $fetch(`/api/transactions/${id.value}`, { method: 'DELETE' })
|
|
await navigateTo('/')
|
|
} catch {
|
|
showToast('거래를 삭제하지 못했습니다.')
|
|
} finally {
|
|
pending.value = false
|
|
}
|
|
}
|
|
|
|
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
|
|
try {
|
|
const created = await $fetch<any>('/api/transactions', {
|
|
method: 'POST',
|
|
body: { ...payload(), occurredAt: dateOnlyToNoon(formatDateOnly()) }
|
|
})
|
|
await navigateTo(`/transactions/${created.id}`)
|
|
} 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>
|