Files
money.sori.studio/pages/stats.vue
2026-08-13 15:58:42 +09:00

211 lines
9.5 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-screen bg-paper">
<header class="sticky top-0 z-10 border-b border-line bg-white">
<div class="flex h-16 items-center justify-between px-4">
<div class="flex items-center gap-2">
<button class="flex h-10 w-10 items-center justify-center rounded-md text-3xl leading-none active:bg-paper" type="button" @click="moveMonth(-1)">‹</button>
<button class="rounded-md px-1 py-2 text-left text-2xl font-semibold active:bg-paper" type="button" @click="monthPickerOpen = true">
{{ displayMonth }}
</button>
<button class="flex h-10 w-10 items-center justify-center rounded-md text-3xl leading-none active:bg-paper" type="button" @click="moveMonth(1)">›</button>
</div>
<button class="btn-secondary min-h-10 px-3 text-base" type="button" @click="periodSheetOpen = true">
월간 ⌄
</button>
</div>
<div class="grid grid-cols-2 border-t border-line">
<button
type="button"
class="relative px-4 py-4 text-center text-lg 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-4 text-center text-lg 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-5">
<div class="flex items-end justify-between">
<div>
<p class="text-sm font-semibold text-muted">{{ selectedType === 'income' ? '수입 합계' : '지출 합계' }}</p>
<p class="mt-1 text-3xl font-bold">{{ formatWon(selectedTotal) }}</p>
</div>
<div class="text-right">
<p class="text-sm font-semibold text-muted">차액</p>
<p class="mt-1 text-lg font-bold" :class="summary.diff >= 0 ? 'text-brand-700' : 'text-red-600'">
{{ formatWon(summary.diff) }}
</p>
</div>
</div>
</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-4 text-left active:bg-paper"
>
<span
class="flex h-9 w-16 shrink-0 items-center justify-center rounded-md text-sm 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-lg font-semibold">{{ item.name }}</span>
<span v-if="item.children.length" class="mt-1 block truncate text-sm text-muted">
{{ item.children.map((child: any) => child.name).join(' · ') }}
</span>
</span>
<span class="shrink-0 text-lg font-semibold">{{ formatWon(item.total) }}</span>
</button>
<p v-if="!categoryTotals.length" class="px-4 py-12 text-center text-muted">
{{ selectedType === 'income' ? '집계할 수입이 없습니다.' : '집계할 지출이 없습니다.' }}
</p>
</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="monthPickerOpen || periodSheetOpen" class="fixed inset-0 z-40 bg-ink/35" @click="closeSheets" />
</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="monthPickerOpen" 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" />
<div class="flex items-center justify-between">
<h2 class="text-lg font-bold">월 선택</h2>
<button type="button" class="text-sm font-semibold text-muted" @click="monthPickerOpen = false">닫기</button>
</div>
</div>
<div class="flex items-center justify-between px-4 py-4">
<button class="text-3xl" type="button" @click="pickerYear--">‹</button>
<strong class="text-2xl">{{ pickerYear }}</strong>
<button class="text-3xl" type="button" @click="pickerYear++">›</button>
</div>
<div class="grid grid-cols-4 gap-y-2 px-4 pb-6">
<button
v-for="month in 12"
:key="month"
type="button"
class="rounded-md py-4 text-lg font-bold"
:class="isSelectedPickerMonth(month) ? 'bg-red-50 text-red-500' : 'active:bg-paper'"
@click="selectPickerMonth(month)"
>
{{ month }}월
</button>
</div>
</section>
</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-4 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">
import { addMonths, format, parse } from 'date-fns'
type StatType = 'income' | 'expense'
const selectedMonth = ref(monthKey())
const selectedType = ref<StatType>('expense')
const monthPickerOpen = ref(false)
const periodSheetOpen = ref(false)
const pickerYear = ref(Number(selectedMonth.value.slice(0, 4)))
const { data } = await useFetch('/api/transactions', { query: { month: selectedMonth } })
const summary = computed(() => data.value?.summary || { income: 0, expense: 0, diff: 0 })
const displayMonth = computed(() => {
const date = parse(selectedMonth.value, 'yyyy-MM', new Date())
return format(date, 'yyyy년 M월')
})
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 parent = tx.category.parent || tx.category
const current = map.get(parent.id) || { id: parent.id, name: parent.name, total: 0, children: new Map() }
current.total += tx.amount
if (tx.category.parent) {
const child = current.children.get(tx.category.id) || { id: tx.category.id, name: tx.category.name, total: 0 }
child.total += tx.amount
current.children.set(tx.category.id, child)
}
map.set(parent.id, current)
}
return Array.from(map.values())
.map(item => ({
...item,
percent: selectedTotal.value ? Math.round((item.total / selectedTotal.value) * 100) : 0,
children: Array.from(item.children.values())
}))
.sort((a, b) => b.total - a.total)
})
watch(monthPickerOpen, (open) => {
if (open) pickerYear.value = Number(selectedMonth.value.slice(0, 4))
})
watch([monthPickerOpen, periodSheetOpen], ([monthOpen, periodOpen]) => {
if (import.meta.server) return
document.body.style.overflow = monthOpen || periodOpen ? 'hidden' : ''
})
onBeforeUnmount(() => {
if (import.meta.client) document.body.style.overflow = ''
})
function moveMonth(amount: number) {
const date = parse(selectedMonth.value, 'yyyy-MM', new Date())
selectedMonth.value = format(addMonths(date, amount), 'yyyy-MM')
}
function isSelectedPickerMonth(month: number) {
return selectedMonth.value === `${pickerYear.value}-${String(month).padStart(2, '0')}`
}
function selectPickerMonth(month: number) {
selectedMonth.value = `${pickerYear.value}-${String(month).padStart(2, '0')}`
monthPickerOpen.value = false
}
function closeSheets() {
monthPickerOpen.value = false
periodSheetOpen.value = false
}
</script>