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

240 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-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
v-if="selectedAsset"
type="button"
class="flex h-10 w-10 items-center justify-center rounded-md text-3xl leading-none active:bg-paper"
aria-label="자산 목록으로"
@click="selectedAssetId = ''"
>
‹
</button>
<h1 class="text-xl font-bold">{{ selectedAsset?.name || '자산' }}</h1>
</div>
<div class="flex items-center gap-1">
<button class="flex h-10 w-9 items-center justify-center rounded-md text-3xl leading-none active:bg-paper" type="button" @click="moveMonth(-1)">‹</button>
<button class="rounded-md px-2 py-2 text-lg font-semibold active:bg-paper" type="button" @click="monthPickerOpen = true">
{{ displayMonth }}
</button>
<button class="flex h-10 w-9 items-center justify-center rounded-md text-3xl leading-none active:bg-paper" type="button" @click="moveMonth(1)">›</button>
</div>
</div>
<div v-if="!selectedAsset" class="grid grid-cols-3 border-t border-line px-4 py-4 text-center">
<div>
<p class="text-sm font-semibold text-muted">누적형</p>
<p class="mt-1 font-bold text-brand-700">{{ formatWon(cumulativeTotal) }}</p>
</div>
<div>
<p class="text-sm font-semibold text-muted">월별</p>
<p class="mt-1 font-bold text-red-600">{{ formatWon(monthlyTotal) }}</p>
</div>
<div>
<p class="text-sm font-semibold text-muted">합계</p>
<p class="mt-1 font-bold">{{ formatWon(totalBalance) }}</p>
</div>
</div>
</header>
<div v-if="!selectedAsset" class="divide-y divide-line border-b border-line bg-white">
<button
v-for="asset in assets"
:key="asset.id"
type="button"
class="flex w-full items-center justify-between px-4 py-5 text-left active:bg-paper"
@click="selectedAssetId = asset.id"
>
<span>
<span class="block text-lg font-semibold text-ink">{{ asset.name }}</span>
<span class="mt-1 block text-sm text-muted">{{ asset.balanceType === 'cumulative' ? '계속 누적' : `${displayMonth}만 계산` }}</span>
</span>
<span class="text-xl font-bold">{{ formatWon(asset.balance || 0) }}</span>
</button>
<p v-if="!assets.length" class="px-4 py-12 text-center text-muted">등록된 자산이 없습니다.</p>
</div>
<div v-else>
<section class="border-b border-line bg-white px-4 py-5">
<p class="text-sm font-semibold text-muted">현재 표시 잔액</p>
<p class="mt-1 text-3xl font-bold">{{ formatWon(selectedAsset.balance || 0) }}</p>
<div class="mt-5 grid grid-cols-3 text-center">
<div>
<p class="text-sm text-muted">입금</p>
<p class="mt-1 font-bold text-brand-700">{{ formatWon(assetSummary.income) }}</p>
</div>
<div>
<p class="text-sm text-muted">출금</p>
<p class="mt-1 font-bold text-red-600">{{ formatWon(assetSummary.expense) }}</p>
</div>
<div>
<p class="text-sm text-muted">합계</p>
<p class="mt-1 font-bold">{{ formatWon(assetSummary.diff) }}</p>
</div>
</div>
</section>
<div class="divide-y divide-line bg-white">
<section v-for="group in groupedTransactions" :key="group.date" class="py-2">
<div class="flex items-center justify-between px-4 py-2">
<div class="flex items-center gap-2">
<strong class="text-3xl">{{ group.day }}</strong>
<span class="rounded bg-slate-200 px-2 py-1 text-sm font-semibold text-muted">{{ group.weekday }}</span>
<span class="text-sm text-muted">{{ group.monthLabel }}</span>
</div>
<div class="flex gap-4 text-sm font-bold">
<span class="text-brand-700">{{ formatWon(group.income) }}</span>
<span class="text-red-600">{{ formatWon(group.expense) }}</span>
</div>
</div>
<NuxtLink
v-for="tx in group.items"
:key="tx.id"
:to="`/transactions/${tx.id}`"
class="grid grid-cols-[1fr_auto] gap-3 px-4 py-3"
>
<div class="min-w-0">
<p class="truncate font-semibold">{{ tx.title }}</p>
<p class="mt-1 truncate text-sm text-muted">{{ categoryLabel(tx.category) }}</p>
</div>
<div class="text-right">
<p class="font-bold" :class="tx.type === 'income' ? 'text-brand-700' : 'text-red-600'">
{{ formatWon(tx.amount) }}
</p>
<p class="mt-1 text-sm text-muted">{{ timeLabel(tx.occurredAt) }}</p>
</div>
</NuxtLink>
</section>
<p v-if="!assetTransactions.length" class="px-4 py-12 text-center text-muted">
이 자산의 {{ displayMonth }} 거래가 없습니다.
</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="monthPickerOpen" class="fixed inset-0 z-40 bg-ink/35" @click="monthPickerOpen = 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="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>
</Teleport>
</section>
</template>
<script setup lang="ts">
import { addMonths, format, parse } from 'date-fns'
import type { Category, Transaction } from '~/composables/useMoney'
const selectedMonth = ref(monthKey())
const selectedAssetId = ref('')
const monthPickerOpen = ref(false)
const pickerYear = ref(Number(selectedMonth.value.slice(0, 4)))
const { data: assetData } = await useFetch('/api/assets', { query: { month: selectedMonth } })
const { data: transactionData } = await useFetch('/api/transactions', { query: { month: selectedMonth } })
const assets = computed(() => assetData.value || [])
const selectedAsset = computed(() => assets.value.find((asset: any) => asset.id === selectedAssetId.value))
const displayMonth = computed(() => {
const date = parse(selectedMonth.value, 'yyyy-MM', new Date())
return format(date, 'yyyy년 M월')
})
const cumulativeTotal = computed(() => assets.value.filter((asset: any) => asset.balanceType === 'cumulative').reduce((sum: number, asset: any) => sum + (asset.balance || 0), 0))
const monthlyTotal = computed(() => assets.value.filter((asset: any) => asset.balanceType === 'monthly_reset').reduce((sum: number, asset: any) => sum + (asset.balance || 0), 0))
const totalBalance = computed(() => assets.value.reduce((sum: number, asset: any) => sum + (asset.balance || 0), 0))
const assetTransactions = computed(() => {
return (transactionData.value?.transactions || []).filter((tx: Transaction) => tx.assetId === selectedAssetId.value)
})
const assetSummary = computed(() => {
return assetTransactions.value.reduce((acc: any, tx: Transaction) => {
acc[tx.type] += tx.amount
acc.diff = acc.income - acc.expense
return acc
}, { income: 0, expense: 0, diff: 0 })
})
const groupedTransactions = computed(() => {
const groups = new Map<string, any>()
for (const tx of assetTransactions.value) {
const date = new Date(tx.occurredAt)
const key = format(date, 'yyyy-MM-dd')
const group = groups.get(key) || {
date: key,
day: format(date, 'd'),
weekday: new Intl.DateTimeFormat('ko-KR', { weekday: 'short' }).format(date),
monthLabel: format(date, 'yyyy.MM'),
income: 0,
expense: 0,
items: []
}
group[tx.type] += tx.amount
group.items.push(tx)
groups.set(key, group)
}
return Array.from(groups.values()).sort((a, b) => b.date.localeCompare(a.date))
})
watch(monthPickerOpen, (open) => {
if (open) pickerYear.value = Number(selectedMonth.value.slice(0, 4))
})
watch(monthPickerOpen, (open) => {
if (import.meta.server) return
document.body.style.overflow = open ? '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 timeLabel(value: string) {
return new Intl.DateTimeFormat('ko-KR', { hour: '2-digit', minute: '2-digit' }).format(new Date(value))
}
function categoryLabel(category: Category & { parent?: Category | null }) {
return category.parent ? `${category.parent.name} > ${category.name}` : category.name
}
</script>