402 lines
16 KiB
Vue
402 lines
16 KiB
Vue
<template>
|
|
<section>
|
|
<header class="sticky top-0 z-10 border-b border-line bg-white">
|
|
<div class="flex h-14 items-center justify-between px-4 md:px-5">
|
|
<button type="button" class="flex h-9 w-9 items-center justify-center rounded-md active:bg-paper" aria-label="뒤로" @click="goBack">
|
|
<img class="h-7 w-7 opacity-80" src="~/assets/icons/keyboard_arrow_left.svg" alt="">
|
|
</button>
|
|
<h1 class="text-lg font-extrabold">최근 이용 내역</h1>
|
|
<div class="flex items-center gap-1">
|
|
<button
|
|
type="button"
|
|
class="flex h-9 w-9 items-center justify-center rounded-md text-ink active:bg-paper"
|
|
aria-label="검색"
|
|
@click="searchOpen = !searchOpen"
|
|
>
|
|
<span class="nav-icon h-6 w-6" v-html="searchIcon" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="relative flex h-9 w-9 items-center justify-center rounded-md text-ink active:bg-paper"
|
|
aria-label="필터"
|
|
@click="filterSheetOpen = true"
|
|
>
|
|
<span class="nav-icon h-6 w-6" v-html="tuneIcon" />
|
|
<span v-if="activeFilterCount" class="absolute right-1 top-1 h-2 w-2 rounded-full bg-red-500" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="searchOpen" class="border-t border-line px-4 py-2 md:px-5">
|
|
<input
|
|
v-model="searchKeyword"
|
|
class="field min-h-10 text-sm"
|
|
placeholder="내용, 메모, 자산, 카테고리 검색"
|
|
type="search"
|
|
>
|
|
</div>
|
|
|
|
<div class="px-4 pb-4 pt-3 md:px-5">
|
|
<MonthNavigator v-model="selectedMonthModel" label-mode="month" />
|
|
|
|
<div class="mt-4 flex items-end justify-between gap-4">
|
|
<div>
|
|
<p class="text-[34px] font-extrabold leading-none tracking-normal text-ink md:text-4xl">
|
|
{{ formatWon(data?.summary.expense || 0) }}
|
|
</p>
|
|
<p class="mt-2 text-sm font-semibold text-muted">
|
|
{{ monthlyComparisonLabel }}
|
|
</p>
|
|
</div>
|
|
<NuxtLink to="/stats" class="btn-secondary flex shrink-0 gap-2 px-3 text-xs md:px-4">
|
|
소비 분류
|
|
</NuxtLink>
|
|
</div>
|
|
|
|
<div class="mt-5">
|
|
<div class="grid grid-cols-7 text-center text-sm font-bold text-muted">
|
|
<span v-for="day in weekdays" :key="day" class="py-1">{{ day }}</span>
|
|
</div>
|
|
<div
|
|
class="relative mt-1 min-h-[4.85rem] overflow-hidden"
|
|
@touchstart.passive="startWeekSwipe"
|
|
@touchend.passive="endWeekSwipe"
|
|
>
|
|
<Transition
|
|
:name="weekTransitionName"
|
|
mode="out-in"
|
|
>
|
|
<div :key="weekKey" class="grid grid-cols-7 text-center">
|
|
<button
|
|
v-for="day in weekDays"
|
|
:key="day.key"
|
|
type="button"
|
|
class="min-h-[4.35rem] rounded-md px-1 py-2 transition-colors duration-150"
|
|
:class="day.key === selectedDate ? 'text-ink' : day.inMonth ? 'text-muted active:bg-paper' : 'text-slate-300'"
|
|
@click="selectDate(day.key)"
|
|
>
|
|
<span
|
|
class="mx-auto flex h-8 w-8 items-center justify-center rounded-full text-lg font-extrabold transition-colors duration-150"
|
|
:class="day.key === selectedDate ? 'bg-ink text-white' : ''"
|
|
>
|
|
{{ day.day }}
|
|
</span>
|
|
<span class="mt-1 block min-h-7 space-y-0.5 text-[10px] font-bold leading-none">
|
|
<span v-if="day.expenseLabel" class="block truncate text-red-500">{{ day.expenseLabel }}</span>
|
|
<span v-if="day.incomeLabel" class="block truncate text-brand-700">{{ day.incomeLabel }}</span>
|
|
</span>
|
|
</button>
|
|
</div>
|
|
</Transition>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
<div class="divide-y divide-line bg-white">
|
|
<section v-for="group in groupedTransactions" :id="`date-${group.date}`" :key="group.date" class="scroll-mt-80 py-2">
|
|
<h2 class="px-4 py-3 text-sm font-semibold text-muted md:px-5">
|
|
{{ group.label }}
|
|
</h2>
|
|
<TransactionListRow
|
|
v-for="tx in group.items"
|
|
:key="tx.id"
|
|
:transaction="tx"
|
|
/>
|
|
</section>
|
|
</div>
|
|
|
|
<EmptyState
|
|
v-if="!data?.transactions.length"
|
|
title="아직 거래가 없습니다."
|
|
description="오른쪽 아래 추가 버튼으로 첫 수입이나 지출을 기록해 보세요."
|
|
mark="₩"
|
|
/>
|
|
|
|
<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="filterSheetOpen" class="fixed inset-0 z-40 bg-ink/35" @click="filterSheetOpen = 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="filterSheetOpen" class="fixed inset-x-0 bottom-0 z-50 mx-auto max-h-[84vh] max-w-lg overflow-hidden 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-base font-bold">내역 필터</h2>
|
|
<button type="button" class="text-sm font-semibold text-muted" @click="filterSheetOpen = false">닫기</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="space-y-4 overflow-y-auto px-4 py-4">
|
|
<div>
|
|
<p class="label">유형</p>
|
|
<div class="grid grid-cols-3 gap-2">
|
|
<button
|
|
v-for="option in typeOptions"
|
|
:key="option.value"
|
|
type="button"
|
|
class="rounded-md border px-3 py-2 text-sm font-bold"
|
|
:class="filters.type === option.value ? 'border-red-500 bg-red-50 text-red-600' : 'border-line text-muted'"
|
|
@click="filters.type = option.value"
|
|
>
|
|
{{ option.label }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<p class="label">자산</p>
|
|
<div class="grid grid-cols-2 gap-2">
|
|
<button
|
|
v-for="option in assetFilterOptions"
|
|
:key="option.value || 'all-assets'"
|
|
type="button"
|
|
class="rounded-md border px-3 py-2 text-left text-sm font-bold"
|
|
:class="filters.assetId === option.value ? 'border-red-500 bg-red-50 text-red-600' : 'border-line text-muted'"
|
|
@click="filters.assetId = option.value"
|
|
>
|
|
{{ option.label }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<p class="label">카테고리</p>
|
|
<div class="grid grid-cols-2 gap-2">
|
|
<button
|
|
v-for="option in categoryFilterOptions"
|
|
:key="option.value || 'all-categories'"
|
|
type="button"
|
|
class="rounded-md border px-3 py-2 text-left text-sm font-bold"
|
|
:class="filters.categoryId === option.value ? 'border-red-500 bg-red-50 text-red-600' : 'border-line text-muted'"
|
|
@click="filters.categoryId = option.value"
|
|
>
|
|
{{ option.label }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="grid grid-cols-[1fr_2fr] gap-2 border-t border-line px-4 py-3">
|
|
<button type="button" class="btn-secondary" @click="resetFilters">초기화</button>
|
|
<button type="button" class="btn-primary" @click="filterSheetOpen = false">적용</button>
|
|
</div>
|
|
</section>
|
|
</Transition>
|
|
</Teleport>
|
|
|
|
</section>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { addDays, addMonths, format, parse, startOfWeek } from 'date-fns'
|
|
import type { WeekStartsOn } from 'date-fns'
|
|
import searchIconRaw from '~/assets/icons/search.svg?raw'
|
|
import tuneIconRaw from '~/assets/icons/tune.svg?raw'
|
|
import type { Transaction } from '~/composables/useMoney'
|
|
|
|
const inlineIcon = (icon: string) => icon
|
|
.replace(/fill="[^"]*"/g, 'fill="currentColor"')
|
|
.replace(/height="[^"]*"/, 'height="100%"')
|
|
.replace(/width="[^"]*"/, 'width="100%"')
|
|
const searchIcon = inlineIcon(searchIconRaw)
|
|
const tuneIcon = inlineIcon(tuneIconRaw)
|
|
const selectedDate = ref(formatDateOnly())
|
|
const weekSwipeStartX = ref(0)
|
|
const weekDirection = ref<'next' | 'prev'>('next')
|
|
const searchOpen = ref(false)
|
|
const filterSheetOpen = ref(false)
|
|
const searchKeyword = ref('')
|
|
const filters = reactive({
|
|
type: '',
|
|
assetId: '',
|
|
categoryId: ''
|
|
})
|
|
const { data: preferenceData } = await useFetch('/api/user-settings')
|
|
const weekStartDay = computed(() => Math.min(Math.max(preferenceData.value?.weekStartDay ?? 0, 0), 6) as WeekStartsOn)
|
|
const selectedMonth = computed(() => monthKey(parse(selectedDate.value, 'yyyy-MM-dd', new Date())))
|
|
const weekKey = computed(() => format(startOfWeek(parse(selectedDate.value, 'yyyy-MM-dd', new Date()), { weekStartsOn: weekStartDay.value }), 'yyyy-MM-dd'))
|
|
const weekTransitionName = computed(() => weekDirection.value === 'next' ? 'week-next' : 'week-prev')
|
|
const selectedMonthModel = computed({
|
|
get: () => selectedMonth.value,
|
|
set: (value: string) => {
|
|
const current = selectedMonth.value
|
|
weekDirection.value = value >= current ? 'next' : 'prev'
|
|
selectedDate.value = `${value}-01`
|
|
}
|
|
})
|
|
const previousMonth = computed(() => {
|
|
const date = parse(selectedMonth.value, 'yyyy-MM', new Date())
|
|
return format(addMonths(date, -1), 'yyyy-MM')
|
|
})
|
|
const { data } = await useFetch('/api/transactions', {
|
|
query: {
|
|
month: selectedMonth,
|
|
q: searchKeyword,
|
|
type: computed(() => filters.type),
|
|
assetId: computed(() => filters.assetId),
|
|
categoryId: computed(() => filters.categoryId)
|
|
}
|
|
})
|
|
const { data: previousData } = await useFetch('/api/transactions', {
|
|
key: computed(() => `transactions-previous-${previousMonth.value}`),
|
|
query: { month: previousMonth }
|
|
})
|
|
const { data: assetData } = await useFetch('/api/assets')
|
|
const { data: categoryData } = await useFetch('/api/categories')
|
|
const weekdayLabels = ['일', '월', '화', '수', '목', '금', '토']
|
|
const weekdays = computed(() => Array.from({ length: 7 }, (_, index) => weekdayLabels[(weekStartDay.value + index) % 7]))
|
|
const typeOptions = [
|
|
{ value: '', label: '전체' },
|
|
{ value: 'income', label: '수입' },
|
|
{ value: 'expense', label: '지출' }
|
|
]
|
|
|
|
const groupedTransactions = computed(() => {
|
|
const groups = new Map<string, { date: string; label: string; items: Transaction[] }>()
|
|
for (const tx of data.value?.transactions || []) {
|
|
const date = new Date(tx.occurredAt)
|
|
const key = format(date, 'yyyy-MM-dd')
|
|
const group = groups.get(key) || { date: key, label: dateLabel(date), items: [] }
|
|
group.items.push(tx)
|
|
groups.set(key, group)
|
|
}
|
|
return Array.from(groups.values()).sort((a, b) => b.date.localeCompare(a.date))
|
|
})
|
|
|
|
const weekDays = computed(() => {
|
|
const base = parse(selectedDate.value, 'yyyy-MM-dd', new Date())
|
|
const start = startOfWeek(base, { weekStartsOn: weekStartDay.value })
|
|
return Array.from({ length: 7 }, (_, index) => {
|
|
const date = addDays(start, index)
|
|
const key = format(date, 'yyyy-MM-dd')
|
|
const summary = daySummary(key)
|
|
return {
|
|
key,
|
|
day: format(date, 'd'),
|
|
inMonth: monthKey(date) === selectedMonth.value,
|
|
expenseLabel: summary.expense ? shortWon(summary.expense) : '',
|
|
incomeLabel: summary.income ? shortWon(summary.income) : ''
|
|
}
|
|
})
|
|
})
|
|
|
|
const monthlyComparisonLabel = computed(() => {
|
|
const currentExpense = data.value?.summary.expense || 0
|
|
const previousExpense = previousData.value?.summary.expense || 0
|
|
const diff = previousExpense - currentExpense
|
|
if (!previousExpense && !currentExpense) return '이번 달 지출 내역을 기다리는 중'
|
|
if (diff > 0) return `지난달보다 ${compactWon(diff)} 덜 쓰는 중`
|
|
if (diff < 0) return `지난달보다 ${compactWon(Math.abs(diff))} 더 쓰는 중`
|
|
return '지난달과 같은 속도로 쓰는 중'
|
|
})
|
|
const activeFilterCount = computed(() => [filters.type, filters.assetId, filters.categoryId, searchKeyword.value.trim()].filter(Boolean).length)
|
|
const assetFilterOptions = computed(() => [
|
|
{ value: '', label: '전체 자산' },
|
|
...(assetData.value || []).map((asset: any) => ({ value: asset.id, label: asset.name }))
|
|
])
|
|
const categoryFilterOptions = computed(() => [
|
|
{ value: '', label: '전체 카테고리' },
|
|
...(categoryData.value || [])
|
|
.filter((category: any) => !filters.type || category.type === filters.type)
|
|
.map((category: any) => ({ value: category.id, label: category.name }))
|
|
])
|
|
|
|
watch(() => filters.type, () => {
|
|
if (!filters.categoryId) return
|
|
const selected = (categoryData.value || []).find((category: any) => category.id === filters.categoryId)
|
|
if (selected && selected.type !== filters.type) filters.categoryId = ''
|
|
})
|
|
|
|
function moveWeek(amount: number) {
|
|
weekDirection.value = amount > 0 ? 'next' : 'prev'
|
|
const date = parse(selectedDate.value, 'yyyy-MM-dd', new Date())
|
|
selectedDate.value = format(addDays(date, amount * 7), 'yyyy-MM-dd')
|
|
}
|
|
|
|
function dateLabel(date: Date) {
|
|
const key = format(date, 'yyyy-MM-dd')
|
|
const today = format(new Date(), 'yyyy-MM-dd')
|
|
const yesterday = format(new Date(Date.now() - 24 * 60 * 60 * 1000), 'yyyy-MM-dd')
|
|
const weekday = new Intl.DateTimeFormat('ko-KR', { weekday: 'short' }).format(date)
|
|
const relative = key === today ? ' · 오늘' : key === yesterday ? ' · 어제' : ''
|
|
return `${format(date, 'M. d')}(${weekday})${relative}`
|
|
}
|
|
|
|
async function selectDate(value: string) {
|
|
selectedDate.value = value
|
|
await nextTick()
|
|
document.getElementById(`date-${value}`)?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
|
}
|
|
|
|
function daySummary(key: string) {
|
|
return (data.value?.transactions || []).reduce((acc, tx) => {
|
|
if (format(new Date(tx.occurredAt), 'yyyy-MM-dd') !== key) return acc
|
|
acc[tx.type] += tx.amount
|
|
acc.diff = acc.income - acc.expense
|
|
return acc
|
|
}, { income: 0, expense: 0, diff: 0 })
|
|
}
|
|
|
|
function shortWon(value: number) {
|
|
if (value >= 10000) return `${Math.round(value / 10000).toLocaleString('ko-KR')}만`
|
|
return value.toLocaleString('ko-KR')
|
|
}
|
|
|
|
function compactWon(value: number) {
|
|
if (value >= 10000) return `${Math.round(value / 10000).toLocaleString('ko-KR')}만원`
|
|
return formatWon(value)
|
|
}
|
|
|
|
function startWeekSwipe(event: TouchEvent) {
|
|
weekSwipeStartX.value = event.changedTouches[0]?.clientX || 0
|
|
}
|
|
|
|
function endWeekSwipe(event: TouchEvent) {
|
|
const endX = event.changedTouches[0]?.clientX || 0
|
|
const diff = endX - weekSwipeStartX.value
|
|
if (Math.abs(diff) < 40) return
|
|
moveWeek(diff < 0 ? 1 : -1)
|
|
}
|
|
|
|
function resetFilters() {
|
|
filters.type = ''
|
|
filters.assetId = ''
|
|
filters.categoryId = ''
|
|
searchKeyword.value = ''
|
|
}
|
|
|
|
function goBack() {
|
|
if (history.length > 1) history.back()
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
.week-next-enter-active,
|
|
.week-next-leave-active,
|
|
.week-prev-enter-active,
|
|
.week-prev-leave-active {
|
|
transition: transform 220ms ease, opacity 220ms ease;
|
|
}
|
|
|
|
.week-next-enter-from {
|
|
opacity: 0;
|
|
transform: translateX(28%);
|
|
}
|
|
|
|
.week-next-leave-to {
|
|
opacity: 0;
|
|
transform: translateX(-28%);
|
|
}
|
|
|
|
.week-prev-enter-from {
|
|
opacity: 0;
|
|
transform: translateX(-28%);
|
|
}
|
|
|
|
.week-prev-leave-to {
|
|
opacity: 0;
|
|
transform: translateX(28%);
|
|
}
|
|
</style>
|