210 lines
9.8 KiB
Vue
210 lines
9.8 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">
|
|
<div class="flex items-center gap-2">
|
|
<button class="flex h-9 w-9 items-center justify-center rounded-md active:bg-paper" type="button" @click="moveMonth(-1)" aria-label="이전 달">
|
|
<img class="h-6 w-6 opacity-70" src="~/assets/icons/keyboard_arrow_left.svg" alt="">
|
|
</button>
|
|
<button class="rounded-md px-1 py-2 text-left text-lg font-semibold active:bg-paper" type="button" @click="monthPickerOpen = true">
|
|
{{ displayMonth }}
|
|
</button>
|
|
<button class="flex h-9 w-9 items-center justify-center rounded-md active:bg-paper" type="button" @click="moveMonth(1)" aria-label="다음 달">
|
|
<img class="h-6 w-6 opacity-70" src="~/assets/icons/keyboard_arrow_right.svg" alt="">
|
|
</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-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-4">
|
|
<div class="flex items-end justify-between">
|
|
<div>
|
|
<p class="text-sm font-semibold text-muted">{{ selectedType === 'income' ? '수입 합계' : '지출 합계' }}</p>
|
|
<p class="mt-1 text-2xl 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-3 text-left active:bg-paper"
|
|
>
|
|
<span
|
|
class="flex h-8 w-14 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-base 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-base 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="flex h-9 w-9 items-center justify-center rounded-md active:bg-paper" type="button" @click="pickerYear--" aria-label="이전 년도">
|
|
<img class="h-6 w-6 opacity-70" src="~/assets/icons/keyboard_arrow_left.svg" alt="">
|
|
</button>
|
|
<strong class="text-xl">{{ pickerYear }}</strong>
|
|
<button class="flex h-9 w-9 items-center justify-center rounded-md active:bg-paper" type="button" @click="pickerYear++" aria-label="다음 년도">
|
|
<img class="h-6 w-6 opacity-70" src="~/assets/icons/keyboard_arrow_right.svg" alt="">
|
|
</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-3 text-base 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-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">
|
|
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))
|
|
})
|
|
|
|
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>
|