273 lines
11 KiB
Vue
273 lines
11 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
|
||
v-if="view !== 'home'"
|
||
type="button"
|
||
class="flex h-9 w-9 items-center justify-center rounded-md text-2xl leading-none active:bg-paper"
|
||
aria-label="뒤로"
|
||
@click="goBack"
|
||
>
|
||
‹
|
||
</button>
|
||
<h1 class="text-lg font-bold">{{ title }}</h1>
|
||
</div>
|
||
|
||
<button
|
||
v-if="showAddButton"
|
||
type="button"
|
||
class="flex h-9 w-9 items-center justify-center rounded-md text-2xl leading-none text-brand-700 active:bg-paper"
|
||
aria-label="추가"
|
||
@click="openAddSheet"
|
||
>
|
||
+
|
||
</button>
|
||
</header>
|
||
|
||
<div v-if="view === 'home'">
|
||
<div class="border-b border-line bg-paper px-4 py-3 text-xs font-semibold text-muted">관리</div>
|
||
<div class="divide-y divide-line bg-white">
|
||
<button
|
||
v-for="item in menuItems"
|
||
:key="item.id"
|
||
type="button"
|
||
class="flex w-full items-center justify-between px-4 py-4 text-left active:bg-paper"
|
||
@click="open(item.id)"
|
||
>
|
||
<span>
|
||
<span class="block text-base font-semibold text-ink">{{ item.label }}</span>
|
||
<span class="mt-1 block text-xs text-muted">{{ item.description }}</span>
|
||
</span>
|
||
<span class="text-xl text-muted">›</span>
|
||
</button>
|
||
</div>
|
||
|
||
<div class="mt-3 border-y border-line bg-white">
|
||
<a
|
||
class="flex items-center justify-between px-4 py-4 active:bg-paper"
|
||
href="/api/export/transactions.csv"
|
||
>
|
||
<span>
|
||
<span class="block text-base font-semibold text-ink">CSV 내보내기</span>
|
||
<span class="mt-1 block text-xs text-muted">전체 거래 데이터를 파일로 저장합니다.</span>
|
||
</span>
|
||
<span class="text-xl text-muted">↓</span>
|
||
</a>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-else-if="view === 'assets'" class="px-4 py-4">
|
||
<form class="space-y-3 border-b border-line pb-5" @submit.prevent="createAsset">
|
||
<input v-model="assetForm.name" class="field" placeholder="자산 이름" required>
|
||
<div>
|
||
<span class="label">잔액 계산 방식</span>
|
||
<div class="space-y-2">
|
||
<label class="flex gap-3 rounded-md border border-line bg-white p-3">
|
||
<input v-model="assetForm.balanceType" type="radio" value="cumulative">
|
||
<span>
|
||
<strong class="block">계속 누적</strong>
|
||
<small class="text-muted">이전 달의 잔액을 계속 이어갑니다.</small>
|
||
</span>
|
||
</label>
|
||
<label class="flex gap-3 rounded-md border border-line bg-white p-3">
|
||
<input v-model="assetForm.balanceType" type="radio" value="monthly_reset">
|
||
<span>
|
||
<strong class="block">매월 새로 시작</strong>
|
||
<small class="text-muted">매월 1일 잔액을 0원부터 계산합니다.</small>
|
||
</span>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
<input v-model.number="assetForm.initialBalance" class="field text-right" inputmode="numeric" type="number" placeholder="최초 잔액">
|
||
<button class="btn-primary w-full" type="submit">자산 추가</button>
|
||
</form>
|
||
|
||
<div class="mt-4 divide-y divide-line border-y border-line bg-white">
|
||
<div v-for="asset in assets" :key="asset.id" class="flex items-center justify-between gap-3 py-3">
|
||
<div>
|
||
<p class="font-semibold">{{ asset.name }}</p>
|
||
<p class="mt-1 text-xs text-muted">
|
||
{{ asset.balanceType === 'cumulative' ? '계속 누적' : '매월 새로 시작' }} · {{ asset.active ? '활성' : '비활성' }}
|
||
</p>
|
||
</div>
|
||
<ToggleSwitch :model-value="asset.active" @update:model-value="toggleAsset(asset, $event)" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-else class="py-4">
|
||
<div v-if="!selectedParentCategory" class="px-4">
|
||
<div class="segmented">
|
||
<button
|
||
type="button"
|
||
class="segmented-button"
|
||
:class="{ 'segmented-button-active': categoryType === 'income' }"
|
||
@click="categoryType = 'income'"
|
||
>
|
||
수입
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class="segmented-button"
|
||
:class="{ 'segmented-button-active': categoryType === 'expense' }"
|
||
@click="categoryType = 'expense'"
|
||
>
|
||
지출
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="mt-4 divide-y divide-line border-y border-line bg-white">
|
||
<button
|
||
v-for="category in visibleCategories"
|
||
:key="category.id"
|
||
type="button"
|
||
class="flex w-full items-center justify-between px-4 py-4 text-left active:bg-paper"
|
||
@click="selectCategory(category)"
|
||
>
|
||
<span>
|
||
<span class="block text-base font-semibold text-ink">{{ category.name }}</span>
|
||
<span class="mt-1 block text-xs text-muted">{{ categoryDescription(category) }}</span>
|
||
</span>
|
||
<span v-if="!selectedParentCategory" class="text-xl text-muted">›</span>
|
||
</button>
|
||
|
||
<p v-if="!visibleCategories.length" class="px-4 py-12 text-center text-muted">
|
||
{{ selectedParentCategory ? '하위 카테고리가 없습니다.' : `${categoryTypeLabel} 상위 카테고리가 없습니다.` }}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<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="addSheetOpen" class="fixed inset-0 z-40 bg-ink/35" @click="addSheetOpen = 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="addSheetOpen" class="fixed inset-x-0 bottom-0 z-50 mx-auto max-w-lg rounded-t-2xl bg-white shadow-2xl">
|
||
<form class="px-4 pb-6 pt-2" @submit.prevent="createCategory">
|
||
<div class="mx-auto mb-3 h-1 w-10 rounded-full bg-line" />
|
||
<div class="mb-5 flex items-center justify-between">
|
||
<h2 class="text-lg font-bold">{{ addSheetTitle }}</h2>
|
||
<button type="button" class="text-sm font-semibold text-muted" @click="addSheetOpen = false">닫기</button>
|
||
</div>
|
||
<input
|
||
v-model="categoryName"
|
||
class="field"
|
||
:placeholder="selectedParentCategory ? '하위 카테고리 이름' : '상위 카테고리 이름'"
|
||
required
|
||
>
|
||
<button class="btn-primary mt-5 w-full" type="submit">추가하기</button>
|
||
</form>
|
||
</section>
|
||
</Transition>
|
||
</Teleport>
|
||
</section>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
type SettingsView = 'home' | 'assets' | 'categories'
|
||
type CategoryType = 'income' | 'expense'
|
||
|
||
const view = ref<SettingsView>('home')
|
||
const categoryType = ref<CategoryType>('expense')
|
||
const selectedParentId = ref('')
|
||
const addSheetOpen = ref(false)
|
||
const categoryName = ref('')
|
||
const { data: assetData, refresh: refreshAssets } = await useFetch('/api/assets', { query: { includeInactive: true } })
|
||
const { data: categoryData, refresh: refreshCategories } = await useFetch('/api/categories', { query: { includeInactive: true } })
|
||
|
||
const menuItems = [
|
||
{ id: 'assets', label: '자산 관리', description: '자산 추가, 잔액 계산 방식, 활성 상태' },
|
||
{ id: 'categories', label: '카테고리 관리', description: '수입/지출 상위 분류와 하위 분류' }
|
||
] as const
|
||
|
||
const assetForm = reactive({
|
||
name: '',
|
||
balanceType: 'cumulative',
|
||
initialBalance: 0
|
||
})
|
||
|
||
const title = computed(() => {
|
||
if (view.value === 'assets') return '자산 관리'
|
||
if (selectedParentCategory.value) return selectedParentCategory.value.name
|
||
if (view.value === 'categories') return '카테고리 관리'
|
||
return '설정'
|
||
})
|
||
const assets = computed(() => assetData.value || [])
|
||
const categories = computed(() => categoryData.value || [])
|
||
const selectedParentCategory = computed(() => categories.value.find((category: any) => category.id === selectedParentId.value))
|
||
const categoryTypeLabel = computed(() => categoryType.value === 'income' ? '수입' : '지출')
|
||
const visibleCategories = computed(() => {
|
||
if (selectedParentId.value) {
|
||
return categories.value
|
||
.filter((category: any) => category.parentId === selectedParentId.value)
|
||
.sort((a: any, b: any) => a.sortOrder - b.sortOrder)
|
||
}
|
||
|
||
return categories.value
|
||
.filter((category: any) => category.type === categoryType.value && !category.parentId)
|
||
.sort((a: any, b: any) => a.sortOrder - b.sortOrder)
|
||
})
|
||
const showAddButton = computed(() => view.value === 'categories')
|
||
const addSheetTitle = computed(() => selectedParentCategory.value ? `${selectedParentCategory.value.name} 하위 카테고리 추가` : `${categoryTypeLabel.value} 상위 카테고리 추가`)
|
||
|
||
watch(categoryType, () => {
|
||
selectedParentId.value = ''
|
||
})
|
||
|
||
function open(nextView: SettingsView) {
|
||
view.value = nextView
|
||
selectedParentId.value = ''
|
||
}
|
||
|
||
function goBack() {
|
||
if (selectedParentId.value) {
|
||
selectedParentId.value = ''
|
||
return
|
||
}
|
||
view.value = 'home'
|
||
}
|
||
|
||
function openAddSheet() {
|
||
categoryName.value = ''
|
||
addSheetOpen.value = true
|
||
}
|
||
|
||
function selectCategory(category: any) {
|
||
if (selectedParentId.value) return
|
||
selectedParentId.value = category.id
|
||
}
|
||
|
||
function categoryDescription(category: any) {
|
||
if (selectedParentId.value) return categoryTypeLabel.value
|
||
const childCount = categories.value.filter((item: any) => item.parentId === category.id).length
|
||
return `${childCount}개 하위 카테고리`
|
||
}
|
||
|
||
async function createAsset() {
|
||
await $fetch('/api/assets', { method: 'POST', body: assetForm })
|
||
Object.assign(assetForm, { name: '', balanceType: 'cumulative', initialBalance: 0 })
|
||
await refreshAssets()
|
||
}
|
||
|
||
async function toggleAsset(asset: any, active: boolean) {
|
||
await $fetch(`/api/assets/${asset.id}`, { method: 'PATCH', body: { active } })
|
||
await refreshAssets()
|
||
}
|
||
|
||
async function createCategory() {
|
||
await $fetch('/api/categories', {
|
||
method: 'POST',
|
||
body: {
|
||
type: categoryType.value,
|
||
parentId: selectedParentId.value || '',
|
||
name: categoryName.value
|
||
}
|
||
})
|
||
categoryName.value = ''
|
||
addSheetOpen.value = false
|
||
await refreshCategories()
|
||
}
|
||
</script>
|