Add ledger search and filters
This commit is contained in:
@@ -30,3 +30,5 @@
|
|||||||
- 월 지출 하단 설명을 수입/차액 대신 지난달 대비 지출 비교 문구로 단순화했다.
|
- 월 지출 하단 설명을 수입/차액 대신 지난달 대비 지출 비교 문구로 단순화했다.
|
||||||
- 하단 네비게이션과 PC 사이드바에 SVG 메뉴 아이콘을 적용하고 활성 탭 색상을 명확하게 표시했다.
|
- 하단 네비게이션과 PC 사이드바에 SVG 메뉴 아이콘을 적용하고 활성 탭 색상을 명확하게 표시했다.
|
||||||
- SVG 메뉴 아이콘이 사각형으로 보이는 문제를 수정하고 활성 아이콘 배경을 추가했다.
|
- SVG 메뉴 아이콘이 사각형으로 보이는 문제를 수정하고 활성 아이콘 배경을 추가했다.
|
||||||
|
- 내역 검색과 유형/자산/카테고리 필터 바텀 시트를 구현했다.
|
||||||
|
- 거래 목록 API에 검색어, 유형, 자산, 카테고리 필터 query를 추가했다.
|
||||||
|
|||||||
135
pages/index.vue
135
pages/index.vue
@@ -7,11 +7,23 @@
|
|||||||
</button>
|
</button>
|
||||||
<h1 class="text-lg font-extrabold">최근 이용 내역</h1>
|
<h1 class="text-lg font-extrabold">최근 이용 내역</h1>
|
||||||
<div class="flex items-center gap-1">
|
<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-2xl leading-none active:bg-paper" aria-label="검색" @click="searchOpen = !searchOpen">⌕</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="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>
|
</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="px-4 pb-4 pt-3 md:px-5">
|
||||||
<div class="flex items-center gap-2">
|
<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)">
|
<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 v-if="!data?.transactions.length" class="px-4 py-16 text-center text-muted">
|
||||||
아직 거래가 없습니다.
|
아직 거래가 없습니다.
|
||||||
</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="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>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -97,19 +181,40 @@ import type { Transaction } from '~/composables/useMoney'
|
|||||||
|
|
||||||
const selectedDate = ref(formatDateOnly())
|
const selectedDate = ref(formatDateOnly())
|
||||||
const weekSwipeStartX = ref(0)
|
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 selectedMonth = computed(() => monthKey(parse(selectedDate.value, 'yyyy-MM-dd', new Date())))
|
||||||
const previousMonth = computed(() => {
|
const previousMonth = computed(() => {
|
||||||
const date = parse(selectedMonth.value, 'yyyy-MM', new Date())
|
const date = parse(selectedMonth.value, 'yyyy-MM', new Date())
|
||||||
return format(addMonths(date, -1), 'yyyy-MM')
|
return format(addMonths(date, -1), 'yyyy-MM')
|
||||||
})
|
})
|
||||||
const { data } = await useFetch('/api/transactions', {
|
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', {
|
const { data: previousData } = await useFetch('/api/transactions', {
|
||||||
key: computed(() => `transactions-previous-${previousMonth.value}`),
|
key: computed(() => `transactions-previous-${previousMonth.value}`),
|
||||||
query: { month: previousMonth }
|
query: { month: previousMonth }
|
||||||
})
|
})
|
||||||
|
const { data: assetData } = await useFetch('/api/assets')
|
||||||
|
const { data: categoryData } = await useFetch('/api/categories')
|
||||||
const weekdays = ['일', '월', '화', '수', '목', '금', '토']
|
const weekdays = ['일', '월', '화', '수', '목', '금', '토']
|
||||||
|
const typeOptions = [
|
||||||
|
{ value: '', label: '전체' },
|
||||||
|
{ value: 'income', label: '수입' },
|
||||||
|
{ value: 'expense', label: '지출' }
|
||||||
|
]
|
||||||
|
|
||||||
const groupedTransactions = computed(() => {
|
const groupedTransactions = computed(() => {
|
||||||
const groups = new Map<string, { date: string; label: string; items: Transaction[] }>()
|
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))} 더 쓰는 중`
|
if (diff < 0) return `지난달보다 ${compactWon(Math.abs(diff))} 더 쓰는 중`
|
||||||
return '지난달과 같은 속도로 쓰는 중'
|
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) {
|
function moveMonth(amount: number) {
|
||||||
const date = parse(selectedDate.value, 'yyyy-MM-dd', new Date())
|
const date = parse(selectedDate.value, 'yyyy-MM-dd', new Date())
|
||||||
@@ -210,6 +332,13 @@ function endWeekSwipe(event: TouchEvent) {
|
|||||||
moveWeek(diff < 0 ? 1 : -1)
|
moveWeek(diff < 0 ? 1 : -1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resetFilters() {
|
||||||
|
filters.type = ''
|
||||||
|
filters.assetId = ''
|
||||||
|
filters.categoryId = ''
|
||||||
|
searchKeyword.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
function goBack() {
|
function goBack() {
|
||||||
if (history.length > 1) history.back()
|
if (history.length > 1) history.back()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,13 +7,30 @@ export default defineEventHandler(async (event) => {
|
|||||||
const user = await requireUser(event)
|
const user = await requireUser(event)
|
||||||
const query = getQuery(event)
|
const query = getQuery(event)
|
||||||
const month = typeof query.month === 'string' ? query.month : new Date().toISOString().slice(0, 7)
|
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 monthStart = startOfMonth(new Date(`${month}-01T00:00:00`))
|
||||||
const monthEnd = endOfMonth(monthStart)
|
const monthEnd = endOfMonth(monthStart)
|
||||||
|
|
||||||
const transactions = await prisma.transaction.findMany({
|
const transactions = await prisma.transaction.findMany({
|
||||||
where: {
|
where: {
|
||||||
userId: user.id,
|
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: {
|
include: {
|
||||||
asset: true,
|
asset: true,
|
||||||
|
|||||||
3
todo.md
3
todo.md
@@ -11,10 +11,7 @@
|
|||||||
|
|
||||||
## 2. 내역 화면
|
## 2. 내역 화면
|
||||||
|
|
||||||
- 검색 버튼 동작을 구현한다.
|
|
||||||
- 필터 버튼을 바텀 시트로 연결하고 유형, 자산, 카테고리 필터를 제공한다.
|
|
||||||
- 월 제목을 누르면 월 선택 바텀 시트가 열리도록 구현한다.
|
- 월 제목을 누르면 월 선택 바텀 시트가 열리도록 구현한다.
|
||||||
- 주간 날짜 스트립의 스와이프/이동 동작을 더 자연스럽게 다듬는다.
|
|
||||||
- 날짜별 수입/지출 표시가 좁은 화면에서도 겹치지 않도록 최종 조정한다.
|
- 날짜별 수입/지출 표시가 좁은 화면에서도 겹치지 않도록 최종 조정한다.
|
||||||
- 거래 행 왼쪽 아이콘을 카테고리별 아이콘으로 표시한다.
|
- 거래 행 왼쪽 아이콘을 카테고리별 아이콘으로 표시한다.
|
||||||
- 거래 취소/환불 같은 상태 표시 규칙을 정한다.
|
- 거래 취소/환불 같은 상태 표시 규칙을 정한다.
|
||||||
|
|||||||
Reference in New Issue
Block a user