Files
money.sori.studio/pages/stats.vue
2026-08-14 13:27:57 +09:00

134 lines
5.7 KiB
Vue

<template>
<section class="min-h-dvh bg-paper">
<header class="sticky top-0 z-10 border-b border-line bg-white">
<div class="flex h-14 items-center justify-between px-4">
<h1 class="text-lg font-bold">통계</h1>
<button class="btn-secondary min-h-10 px-3 text-base" type="button" @click="periodSheetOpen = true">
월간 ⌄
</button>
</div>
<div class="border-t border-line px-4 py-3">
<MonthNavigator v-model="selectedMonth" />
</div>
<div class="grid grid-cols-2 border-t border-line">
<button
type="button"
class="relative px-4 py-3 text-center text-sm font-bold"
:class="selectedType === 'income' ? 'text-brand-700' : 'text-muted'"
@click="selectedType = 'income'"
>
수입 {{ formatWon(summary.income) }}
<span v-if="selectedType === 'income'" class="absolute inset-x-0 bottom-0 h-1 bg-brand-700" />
</button>
<button
type="button"
class="relative px-4 py-3 text-center text-sm font-bold"
:class="selectedType === 'expense' ? 'text-red-600' : 'text-muted'"
@click="selectedType = 'expense'"
>
지출 {{ formatWon(summary.expense) }}
<span v-if="selectedType === 'expense'" class="absolute inset-x-0 bottom-0 h-1 bg-red-500" />
</button>
</div>
</header>
<div class="bg-white px-4 py-3">
<p class="text-xs font-semibold text-muted">{{ selectedType === 'income' ? '수입 합계' : '지출 합계' }}</p>
<p class="mt-1 text-2xl font-bold">{{ formatWon(selectedTotal) }}</p>
</div>
<div class="mt-3 divide-y divide-line border-y border-line bg-white">
<button
v-for="item in categoryTotals"
:key="item.id"
type="button"
class="flex w-full items-center gap-3 px-4 py-2.5 text-left active:bg-paper"
>
<span
class="flex h-7 w-12 shrink-0 items-center justify-center rounded-md text-xs font-bold text-white"
:class="selectedType === 'income' ? 'bg-brand-600' : 'bg-red-500'"
>
{{ item.percent }}%
</span>
<span class="min-w-0 flex-1">
<span class="block truncate text-sm font-semibold">{{ item.name }}</span>
<span v-if="item.parentName" class="mt-0.5 block truncate text-xs text-muted">
{{ item.parentName }}
</span>
</span>
<span class="shrink-0 text-sm font-semibold">{{ formatWon(item.total) }}</span>
</button>
<EmptyState
v-if="!categoryTotals.length"
:title="selectedType === 'income' ? '집계할 수입이 없습니다.' : '집계할 지출이 없습니다.'"
description="해당 월에 거래를 추가하면 분류별 합계를 바로 볼 수 있습니다."
mark="%"
/>
</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="periodSheetOpen" class="fixed inset-0 z-40 bg-ink/35" @click="periodSheetOpen = 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="periodSheetOpen" class="fixed inset-x-0 bottom-0 z-50 mx-auto max-w-lg rounded-t-2xl bg-white shadow-2xl">
<div class="border-b border-line px-4 pb-3 pt-2">
<div class="mx-auto mb-3 h-1 w-10 rounded-full bg-line" />
<h2 class="text-lg font-bold">조회 단위</h2>
</div>
<div class="px-2 py-2">
<button class="flex w-full items-center justify-between rounded-md px-3 py-3 text-left active:bg-paper" type="button" @click="periodSheetOpen = false">
<span>
<span class="block font-semibold">월간</span>
<span class="mt-1 block text-sm text-muted">현재 구현된 조회 단위입니다.</span>
</span>
<span class="flex h-6 w-6 items-center justify-center rounded-full bg-brand-700 text-white">✓</span>
</button>
<div class="px-3 py-4 text-sm text-muted">분기, 반기, 연도는 다음 단계에서 같은 화면 구조로 확장합니다.</div>
</div>
</section>
</Transition>
</Teleport>
</section>
</template>
<script setup lang="ts">
type StatType = 'income' | 'expense'
const selectedMonth = ref(monthKey())
const selectedType = ref<StatType>('expense')
const periodSheetOpen = ref(false)
const { data } = await useFetch('/api/transactions', { query: { month: selectedMonth } })
const summary = computed(() => data.value?.summary || { income: 0, expense: 0, diff: 0 })
const selectedTotal = computed(() => selectedType.value === 'income' ? summary.value.income : summary.value.expense)
const categoryTotals = computed(() => {
const map = new Map<string, any>()
for (const tx of data.value?.transactions || []) {
if (tx.type !== selectedType.value) continue
const category = tx.category
const current = map.get(category.id) || {
id: category.id,
name: category.name,
parentName: category.parent?.name || '',
total: 0
}
current.total += tx.amount
map.set(category.id, current)
}
return Array.from(map.values())
.map(item => ({
...item,
percent: selectedTotal.value ? Math.round((item.total / selectedTotal.value) * 100) : 0
}))
.sort((a, b) => b.total - a.total)
})
</script>