280 lines
12 KiB
Vue
280 lines
12 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
|
|
v-if="selectedAsset"
|
|
type="button"
|
|
class="flex h-9 w-9 items-center justify-center rounded-md active:bg-paper"
|
|
aria-label="자산 목록으로"
|
|
@click="selectedAssetId = ''"
|
|
>
|
|
<img class="h-6 w-6 opacity-70" src="~/assets/icons/keyboard_arrow_left.svg" alt="">
|
|
</button>
|
|
<h1 class="text-lg font-bold">{{ selectedAsset?.name || '자산' }}</h1>
|
|
</div>
|
|
<div class="h-9 w-9" aria-hidden="true" />
|
|
</div>
|
|
|
|
<div v-if="!selectedAsset" class="border-t border-line px-4 py-3">
|
|
<MonthNavigator v-model="selectedMonth" />
|
|
</div>
|
|
|
|
<div v-if="!selectedAsset" class="grid grid-cols-3 border-t border-line px-4 py-3 text-center">
|
|
<div>
|
|
<p class="text-xs font-semibold text-muted">누적형</p>
|
|
<p class="mt-1 font-bold text-brand-700">{{ formatWon(cumulativeTotal) }}</p>
|
|
</div>
|
|
<div>
|
|
<p class="text-xs font-semibold text-muted">월별</p>
|
|
<p class="mt-1 font-bold text-red-600">{{ formatWon(monthlyTotal) }}</p>
|
|
</div>
|
|
<div>
|
|
<p class="text-xs font-semibold text-muted">합계</p>
|
|
<p class="mt-1 font-bold">{{ formatWon(totalBalance) }}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-else class="border-t border-line px-4 py-3">
|
|
<div class="flex items-center justify-between gap-3">
|
|
<MonthNavigator v-model="selectedMonth" />
|
|
<span class="shrink-0 text-xs font-semibold text-muted">{{ selectedAsset.balanceType === 'cumulative' ? '누적형' : '월별' }}</span>
|
|
</div>
|
|
<div class="mt-3 grid grid-cols-3 rounded-md bg-paper p-1">
|
|
<button
|
|
v-for="option in detailViewOptions"
|
|
:key="option.value"
|
|
type="button"
|
|
class="rounded px-3 py-2 text-xs font-bold transition"
|
|
:class="detailView === option.value ? 'bg-white text-ink shadow-sm' : 'text-muted'"
|
|
@click="detailView = option.value"
|
|
>
|
|
{{ option.label }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
<div
|
|
v-if="!selectedAsset"
|
|
class="divide-y divide-line border-b border-line bg-white md:grid md:grid-cols-2 md:gap-3 md:divide-y-0 md:border-0 md:bg-transparent md:p-5 xl:grid-cols-3"
|
|
>
|
|
<button
|
|
v-for="asset in assets"
|
|
:key="asset.id"
|
|
type="button"
|
|
class="flex w-full items-center justify-between px-4 py-4 text-left active:bg-paper md:min-h-24 md:rounded-md md:border md:border-line md:bg-white md:shadow-sm"
|
|
@click="selectedAssetId = asset.id"
|
|
>
|
|
<span class="min-w-0">
|
|
<span class="block text-base font-semibold text-ink">{{ asset.name }}</span>
|
|
<span class="mt-1 block text-xs text-muted">{{ asset.balanceType === 'cumulative' ? '계속 누적' : `${displayMonth}만 계산` }}</span>
|
|
</span>
|
|
<span class="shrink-0 text-base font-bold">{{ formatWon(asset.balance || 0) }}</span>
|
|
</button>
|
|
<EmptyState
|
|
v-if="!assets.length"
|
|
class="md:col-span-full md:rounded-md md:border md:border-line md:bg-white"
|
|
title="등록된 자산이 없습니다."
|
|
description="설정의 자산 관리에서 사용할 현금, 카드, 계좌를 먼저 추가해 주세요."
|
|
mark="₩"
|
|
/>
|
|
</div>
|
|
|
|
<div v-else class="md:grid md:grid-cols-[20rem_1fr] md:gap-4 md:p-5">
|
|
<section class="border-b border-line bg-white px-4 py-4 md:rounded-md md:border md:border-line md:shadow-sm">
|
|
<p class="text-sm font-semibold text-muted">{{ detailSummaryTitle }}</p>
|
|
<p class="mt-1 text-2xl font-bold">{{ formatWon(selectedAsset.balance || 0) }}</p>
|
|
<div class="mt-5 grid grid-cols-3 gap-2 text-center md:grid-cols-1 md:text-left">
|
|
<div class="md:border-t md:border-line md:pt-3">
|
|
<p class="text-xs font-semibold text-muted">입금</p>
|
|
<p class="mt-1 font-bold text-brand-700">{{ formatWon(currentSummary.income) }}</p>
|
|
</div>
|
|
<div class="md:border-t md:border-line md:pt-3">
|
|
<p class="text-xs font-semibold text-muted">출금</p>
|
|
<p class="mt-1 font-bold text-red-600">{{ formatWon(currentSummary.expense) }}</p>
|
|
</div>
|
|
<div class="md:border-t md:border-line md:pt-3">
|
|
<p class="text-xs font-semibold text-muted">합계</p>
|
|
<p class="mt-1 font-bold">{{ formatWon(currentSummary.diff) }}</p>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<Transition name="asset-panel" mode="out-in">
|
|
<div v-if="detailView === 'daily'" key="daily" class="divide-y divide-line bg-white md:overflow-hidden md:rounded-md md:border md:border-line md:shadow-sm">
|
|
<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-2xl">{{ group.day }}</strong>
|
|
<span class="rounded bg-slate-200 px-2 py-1 text-xs 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>
|
|
|
|
<TransactionListRow
|
|
v-for="tx in group.items"
|
|
:key="tx.id"
|
|
:transaction="tx"
|
|
/>
|
|
</section>
|
|
|
|
<EmptyState
|
|
v-if="!assetTransactions.length"
|
|
:title="`이 자산의 ${displayMonth} 거래가 없습니다.`"
|
|
description="월을 바꾸거나 새 거래를 추가하면 이곳에 자산별 내역이 표시됩니다."
|
|
mark="-"
|
|
/>
|
|
</div>
|
|
|
|
<div v-else-if="detailView === 'monthly'" key="monthly" class="divide-y divide-line bg-white md:overflow-hidden md:rounded-md md:border md:border-line md:shadow-sm">
|
|
<button
|
|
v-for="month in monthlyRows"
|
|
:key="month.key"
|
|
type="button"
|
|
class="grid w-full grid-cols-[4rem_1fr_auto] items-center gap-3 px-4 py-3 text-left active:bg-paper"
|
|
@click="selectedMonth = month.key; detailView = 'daily'"
|
|
>
|
|
<span class="text-lg font-extrabold">{{ month.label }}</span>
|
|
<span class="min-w-0 text-xs font-semibold text-muted">
|
|
입금 <span class="text-brand-700">{{ formatWon(month.income) }}</span>
|
|
· 출금 <span class="text-red-600">{{ formatWon(month.expense) }}</span>
|
|
</span>
|
|
<span class="text-sm font-bold">{{ formatWon(month.diff) }}</span>
|
|
</button>
|
|
</div>
|
|
|
|
<div v-else key="yearly" class="bg-white md:overflow-hidden md:rounded-md md:border md:border-line md:shadow-sm">
|
|
<div class="px-4 py-4">
|
|
<p class="text-sm font-semibold text-muted">{{ selectedYear }}년 합계</p>
|
|
<p class="mt-1 text-2xl font-bold">{{ formatWon(yearSummary.diff) }}</p>
|
|
</div>
|
|
<div class="grid grid-cols-3 border-t border-line px-4 py-3 text-center">
|
|
<div>
|
|
<p class="text-xs font-semibold text-muted">입금</p>
|
|
<p class="mt-1 text-sm font-bold text-brand-700">{{ formatWon(yearSummary.income) }}</p>
|
|
</div>
|
|
<div>
|
|
<p class="text-xs font-semibold text-muted">출금</p>
|
|
<p class="mt-1 text-sm font-bold text-red-600">{{ formatWon(yearSummary.expense) }}</p>
|
|
</div>
|
|
<div>
|
|
<p class="text-xs font-semibold text-muted">거래</p>
|
|
<p class="mt-1 text-sm font-bold">{{ yearlyAssetTransactions.length }}건</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Transition>
|
|
</div>
|
|
|
|
</section>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { format, parse } from 'date-fns'
|
|
import type { Transaction } from '~/composables/useMoney'
|
|
|
|
type DetailView = 'daily' | 'monthly' | 'yearly'
|
|
|
|
const selectedMonth = ref(monthKey())
|
|
const selectedAssetId = ref('')
|
|
const detailView = ref<DetailView>('daily')
|
|
const { data: assetData } = await useFetch('/api/assets', { query: { month: selectedMonth } })
|
|
const { data: transactionData } = await useFetch('/api/transactions', { query: { month: selectedMonth } })
|
|
const selectedYear = computed(() => selectedMonth.value.slice(0, 4))
|
|
const { data: yearTransactionData } = await useFetch('/api/transactions', {
|
|
key: computed(() => `asset-year-transactions-${selectedYear.value}`),
|
|
query: { year: selectedYear }
|
|
})
|
|
const detailViewOptions = [
|
|
{ value: 'daily', label: '일별' },
|
|
{ value: 'monthly', label: '월별' },
|
|
{ value: 'yearly', label: '연별' }
|
|
] as const
|
|
|
|
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 yearlyAssetTransactions = computed(() => {
|
|
return (yearTransactionData.value?.transactions || []).filter((tx: Transaction) => tx.assetId === selectedAssetId.value)
|
|
})
|
|
const monthSummary = computed(() => summarize(assetTransactions.value))
|
|
const yearSummary = computed(() => summarize(yearlyAssetTransactions.value))
|
|
const currentSummary = computed(() => detailView.value === 'daily' ? monthSummary.value : yearSummary.value)
|
|
const detailSummaryTitle = computed(() => detailView.value === 'daily' ? `${displayMonth.value} 표시 잔액` : `${selectedYear.value}년 흐름`)
|
|
const monthlyRows = computed(() => {
|
|
const groups = new Map<string, any>()
|
|
for (const tx of yearlyAssetTransactions.value) {
|
|
const key = format(new Date(tx.occurredAt), 'yyyy-MM')
|
|
const group = groups.get(key) || { key, label: format(new Date(tx.occurredAt), 'M월'), income: 0, expense: 0, diff: 0 }
|
|
group[tx.type] += tx.amount
|
|
group.diff = group.income - group.expense
|
|
groups.set(key, group)
|
|
}
|
|
return Array.from({ length: 12 }, (_, index) => {
|
|
const key = `${selectedYear.value}-${String(index + 1).padStart(2, '0')}`
|
|
return groups.get(key) || { key, label: `${index + 1}월`, income: 0, expense: 0, diff: 0 }
|
|
})
|
|
})
|
|
|
|
watch(selectedAssetId, () => {
|
|
detailView.value = 'daily'
|
|
})
|
|
|
|
function summarize(items: Transaction[]) {
|
|
return items.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))
|
|
})
|
|
|
|
</script>
|
|
|
|
<style scoped>
|
|
.asset-panel-enter-active,
|
|
.asset-panel-leave-active {
|
|
transition: transform 180ms ease, opacity 180ms ease;
|
|
}
|
|
|
|
.asset-panel-enter-from,
|
|
.asset-panel-leave-to {
|
|
opacity: 0;
|
|
transform: translateY(8px);
|
|
}
|
|
</style>
|