Add ledger search and filters
This commit is contained in:
@@ -30,3 +30,5 @@
|
||||
- 월 지출 하단 설명을 수입/차액 대신 지난달 대비 지출 비교 문구로 단순화했다.
|
||||
- 하단 네비게이션과 PC 사이드바에 SVG 메뉴 아이콘을 적용하고 활성 탭 색상을 명확하게 표시했다.
|
||||
- SVG 메뉴 아이콘이 사각형으로 보이는 문제를 수정하고 활성 아이콘 배경을 추가했다.
|
||||
- 내역 검색과 유형/자산/카테고리 필터 바텀 시트를 구현했다.
|
||||
- 거래 목록 API에 검색어, 유형, 자산, 카테고리 필터 query를 추가했다.
|
||||
|
||||
135
pages/index.vue
135
pages/index.vue
@@ -7,11 +7,23 @@
|
||||
</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-2xl leading-none active:bg-paper" aria-label="검색">⌕</button>
|
||||
<button type="button" class="flex h-9 w-9 items-center justify-center rounded-md text-xl leading-none active:bg-paper" aria-label="필터">☷</button>
|
||||
<button type="button" class="flex h-9 w-9 items-center justify-center rounded-md text-2xl leading-none active:bg-paper" aria-label="검색" @click="searchOpen = !searchOpen">⌕</button>
|
||||
<button type="button" class="relative flex h-9 w-9 items-center justify-center rounded-md text-xl leading-none active:bg-paper" aria-label="필터" @click="filterSheetOpen = true">
|
||||
☷
|
||||
<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">
|
||||
<div class="flex items-center gap-2">
|
||||
<button type="button" class="flex h-8 w-8 items-center justify-center rounded-md active:bg-paper" aria-label="이전 달" @click="moveMonth(-1)">
|
||||
@@ -88,6 +100,78 @@
|
||||
<div v-if="!data?.transactions.length" class="px-4 py-16 text-center text-muted">
|
||||
아직 거래가 없습니다.
|
||||
</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="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>
|
||||
|
||||
@@ -97,19 +181,40 @@ import type { Transaction } from '~/composables/useMoney'
|
||||
|
||||
const selectedDate = ref(formatDateOnly())
|
||||
const weekSwipeStartX = ref(0)
|
||||
const searchOpen = ref(false)
|
||||
const filterSheetOpen = ref(false)
|
||||
const searchKeyword = ref('')
|
||||
const filters = reactive({
|
||||
type: '',
|
||||
assetId: '',
|
||||
categoryId: ''
|
||||
})
|
||||
const selectedMonth = computed(() => monthKey(parse(selectedDate.value, 'yyyy-MM-dd', new Date())))
|
||||
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 }
|
||||
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 weekdays = ['일', '월', '화', '수', '목', '금', '토']
|
||||
const typeOptions = [
|
||||
{ value: '', label: '전체' },
|
||||
{ value: 'income', label: '수입' },
|
||||
{ value: 'expense', label: '지출' }
|
||||
]
|
||||
|
||||
const groupedTransactions = computed(() => {
|
||||
const groups = new Map<string, { date: string; label: string; items: Transaction[] }>()
|
||||
@@ -154,6 +259,23 @@ const monthlyComparisonLabel = computed(() => {
|
||||
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 moveMonth(amount: number) {
|
||||
const date = parse(selectedDate.value, 'yyyy-MM-dd', new Date())
|
||||
@@ -210,6 +332,13 @@ function endWeekSwipe(event: TouchEvent) {
|
||||
moveWeek(diff < 0 ? 1 : -1)
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
filters.type = ''
|
||||
filters.assetId = ''
|
||||
filters.categoryId = ''
|
||||
searchKeyword.value = ''
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
if (history.length > 1) history.back()
|
||||
}
|
||||
|
||||
@@ -7,13 +7,30 @@ 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 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 transactions = await prisma.transaction.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
occurredAt: { gte: monthStart, lte: monthEnd }
|
||||
occurredAt: { gte: monthStart, lte: monthEnd },
|
||||
...(type ? { type } : {}),
|
||||
...(assetId ? { assetId } : {}),
|
||||
...(categoryId ? { categoryId } : {}),
|
||||
...(q
|
||||
? {
|
||||
OR: [
|
||||
{ title: { contains: q, mode: 'insensitive' } },
|
||||
{ memo: { contains: q, mode: 'insensitive' } },
|
||||
{ asset: { name: { contains: q, mode: 'insensitive' } } },
|
||||
{ category: { name: { contains: q, mode: 'insensitive' } } }
|
||||
]
|
||||
}
|
||||
: {})
|
||||
},
|
||||
include: {
|
||||
asset: true,
|
||||
|
||||
Reference in New Issue
Block a user