Add asset detail period views
This commit is contained in:
@@ -73,3 +73,5 @@
|
||||
- 자산 화면을 넓은 화면에서 카드 그리드와 상세 2열 구조로 보이도록 조정했다.
|
||||
- 거래 상세 화면의 자주사용 버튼을 실제 템플릿 저장 기능과 연결하고, 거래 추가 화면에서 자주사용 내역을 바텀시트로 불러올 수 있도록 했다.
|
||||
- 거래 삭제 확인을 브라우저 기본 confirm 대신 앱 스타일의 바텀시트로 변경했다.
|
||||
- 거래 목록 API가 월간 조회뿐 아니라 연간 조회도 받을 수 있도록 확장했다.
|
||||
- 자산 상세 화면의 월 이동 위치를 헤더 아래 공통 구조로 맞추고, 일별/월별/연별 보기 전환과 전환 모션을 추가했다.
|
||||
|
||||
171
pages/assets.vue
171
pages/assets.vue
@@ -17,7 +17,7 @@
|
||||
<div class="h-9 w-9" aria-hidden="true" />
|
||||
</div>
|
||||
|
||||
<div class="border-t border-line px-4 py-3">
|
||||
<div v-if="!selectedAsset" class="border-t border-line px-4 py-3">
|
||||
<MonthNavigator v-model="selectedMonth" />
|
||||
</div>
|
||||
|
||||
@@ -35,6 +35,25 @@
|
||||
<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
|
||||
@@ -61,49 +80,89 @@
|
||||
|
||||
<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">현재 표시 잔액</p>
|
||||
<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(assetSummary.income) }}</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(assetSummary.expense) }}</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(assetSummary.diff) }}</p>
|
||||
<p class="mt-1 font-bold">{{ formatWon(currentSummary.diff) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div 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>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<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 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>
|
||||
|
||||
<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>
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
@@ -113,10 +172,23 @@
|
||||
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))
|
||||
@@ -130,13 +202,39 @@ const totalBalance = computed(() => assets.value.reduce((sum: number, asset: any
|
||||
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) => {
|
||||
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) {
|
||||
@@ -159,3 +257,16 @@ const groupedTransactions = computed(() => {
|
||||
})
|
||||
|
||||
</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>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { getQuery } from 'h3'
|
||||
import { startOfMonth, endOfMonth } from 'date-fns'
|
||||
import { startOfMonth, endOfMonth, startOfYear, endOfYear } from 'date-fns'
|
||||
import { prisma } from '../../utils/db'
|
||||
import { requireUser } from '../../utils/auth'
|
||||
|
||||
@@ -7,17 +7,20 @@ export default defineEventHandler(async (event) => {
|
||||
const user = await requireUser(event)
|
||||
const query = getQuery(event)
|
||||
const month = typeof query.month === 'string' ? query.month : new Date().toISOString().slice(0, 7)
|
||||
const year = typeof query.year === 'string' ? query.year : ''
|
||||
const type = query.type === 'income' || query.type === 'expense' ? query.type : undefined
|
||||
const assetId = typeof query.assetId === 'string' && query.assetId ? query.assetId : undefined
|
||||
const categoryId = typeof query.categoryId === 'string' && query.categoryId ? query.categoryId : undefined
|
||||
const q = typeof query.q === 'string' ? query.q.trim() : ''
|
||||
const monthStart = startOfMonth(new Date(`${month}-01T00:00:00`))
|
||||
const monthEnd = endOfMonth(monthStart)
|
||||
const rangeStart = year ? startOfYear(new Date(`${year}-01-01T00:00:00`)) : monthStart
|
||||
const rangeEnd = year ? endOfYear(rangeStart) : monthEnd
|
||||
|
||||
const transactions = await prisma.transaction.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
occurredAt: { gte: monthStart, lte: monthEnd },
|
||||
occurredAt: { gte: rangeStart, lte: rangeEnd },
|
||||
...(type ? { type } : {}),
|
||||
...(assetId ? { assetId } : {}),
|
||||
...(categoryId ? { categoryId } : {}),
|
||||
|
||||
17
todo.md
17
todo.md
@@ -23,40 +23,35 @@
|
||||
|
||||
- 카테고리 정렬 순서 변경 기능을 추가한다.
|
||||
|
||||
## 5. 자산
|
||||
|
||||
- 자산 상세 화면의 월 이동 헤더를 공통 구조에 맞춘다.
|
||||
- 자산 상세에서 일별/월별/연별 보기 전환을 구현한다.
|
||||
|
||||
## 6. 통계
|
||||
## 5. 통계
|
||||
|
||||
- 기간 선택 바텀 시트를 월간, 연간, 사용자 지정 범위로 확장한다.
|
||||
- 그래프가 필요해질 경우 최소한의 막대/도넛 차트를 추가한다.
|
||||
|
||||
## 7. 설정
|
||||
## 6. 설정
|
||||
|
||||
- 설정 홈의 항목 구성을 다시 정리한다.
|
||||
- 주 화폐, 월 시작일, 주간 시작일 같은 기본 설정 항목을 구현한다.
|
||||
- 설정 변경이 실제 화면 계산과 표시 방식에 반영되도록 연결한다.
|
||||
|
||||
## 8. 데이터와 API
|
||||
## 7. 데이터와 API
|
||||
|
||||
- 사용자 설정 테이블을 추가한다.
|
||||
- 거래 목록 조회 성능을 고려해 필요한 index를 점검한다.
|
||||
- CSV 내보내기 컬럼을 실제 사용 흐름에 맞게 정리한다.
|
||||
|
||||
## 9. 인증과 사용자 경험
|
||||
## 8. 인증과 사용자 경험
|
||||
|
||||
- 회원 탈퇴 또는 데이터 삭제 정책을 정한다.
|
||||
- 로그인 실패, 세션 만료, 네트워크 오류 상태를 화면에서 더 명확히 보여준다.
|
||||
- 빈 데이터 상태 화면을 페이지별로 다듬는다.
|
||||
|
||||
## 10. 공유
|
||||
## 9. 공유
|
||||
|
||||
- 공유 링크 재생성 기능을 구현한다.
|
||||
- 공유 접근 화면의 자산/통계 표시를 더 풍부하게 만든다.
|
||||
|
||||
## 11. 품질
|
||||
## 10. 품질
|
||||
|
||||
- 핵심 유틸 함수 테스트를 추가한다.
|
||||
- 거래 생성, 수정, 삭제 API 테스트를 추가한다.
|
||||
|
||||
Reference in New Issue
Block a user