Files
money.sori.studio/pages/index.vue
2026-08-14 13:27:57 +09:00

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>