212 lines
9.5 KiB
Vue
212 lines
9.5 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 text-2xl leading-none active:bg-paper"
|
||
aria-label="자산 목록으로"
|
||
@click="selectedAssetId = ''"
|
||
>
|
||
‹
|
||
</button>
|
||
<h1 class="text-lg font-bold">{{ selectedAsset?.name || '자산' }}</h1>
|
||
</div>
|
||
<div class="flex items-center gap-1">
|
||
<button class="flex h-9 w-8 items-center justify-center rounded-md text-2xl leading-none active:bg-paper" type="button" @click="moveMonth(-1)">‹</button>
|
||
<button class="rounded-md px-2 py-2 text-base font-semibold active:bg-paper" type="button" @click="monthPickerOpen = true">
|
||
{{ displayMonth }}
|
||
</button>
|
||
<button class="flex h-9 w-8 items-center justify-center rounded-md text-2xl 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-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>
|
||
</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-4 text-left active:bg-paper"
|
||
@click="selectedAssetId = asset.id"
|
||
>
|
||
<span>
|
||
<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="text-base 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-4">
|
||
<p class="text-sm font-semibold text-muted">현재 표시 잔액</p>
|
||
<p class="mt-1 text-2xl 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-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>
|
||
|
||
<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-2xl" type="button" @click="pickerYear--">‹</button>
|
||
<strong class="text-xl">{{ pickerYear }}</strong>
|
||
<button class="text-2xl" 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-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>
|
||
</Teleport>
|
||
</section>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { addMonths, format, parse } from 'date-fns'
|
||
import type { 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))
|
||
})
|
||
|
||
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
|
||
}
|
||
|
||
</script>
|