Files
money.sori.studio/pages/settings.vue
2026-08-13 16:27:44 +09:00

273 lines
11 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>