Refine transaction date input and header
This commit is contained in:
1
assets/icons/keyboard_arrow_down.svg
Normal file
1
assets/icons/keyboard_arrow_down.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#e3e3e3"><path d="M480-344 240-584l56-56 184 184 184-184 56 56-240 240Z"/></svg>
|
||||
|
After Width: | Height: | Size: 178 B |
1
assets/icons/keyboard_arrow_left.svg
Normal file
1
assets/icons/keyboard_arrow_left.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#e3e3e3"><path d="M560-240 320-480l240-240 56 56-184 184 184 184-56 56Z"/></svg>
|
||||
|
After Width: | Height: | Size: 178 B |
1
assets/icons/keyboard_arrow_right.svg
Normal file
1
assets/icons/keyboard_arrow_right.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#e3e3e3"><path d="M504-480 320-664l56-56 240 240-240 240-56-56 184-184Z"/></svg>
|
||||
|
After Width: | Height: | Size: 178 B |
1
assets/icons/keyboard_arrow_up.svg
Normal file
1
assets/icons/keyboard_arrow_up.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#e3e3e3"><path d="M480-528 296-344l-56-56 240-240 240 240-56 56-184-184Z"/></svg>
|
||||
|
After Width: | Height: | Size: 179 B |
121
components/BottomSheetDatePicker.vue
Normal file
121
components/BottomSheetDatePicker.vue
Normal file
@@ -0,0 +1,121 @@
|
||||
<template>
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
class="field flex min-h-[50px] items-center text-left font-semibold"
|
||||
aria-haspopup="dialog"
|
||||
:aria-expanded="open"
|
||||
@click="open = true"
|
||||
>
|
||||
{{ displayValue }}
|
||||
</button>
|
||||
|
||||
<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="open" class="fixed inset-0 z-40 bg-ink/35" @click="open = 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="open"
|
||||
class="fixed inset-x-0 bottom-0 z-50 mx-auto max-w-lg overflow-hidden rounded-t-2xl bg-white shadow-2xl"
|
||||
role="dialog"
|
||||
aria-label="날짜 선택"
|
||||
>
|
||||
<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="rounded-md px-2 py-1 text-sm font-semibold text-muted" @click="open = false">닫기</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between px-4 py-4">
|
||||
<button type="button" class="flex h-9 w-9 items-center justify-center rounded-md active:bg-paper" aria-label="이전 달" @click="moveMonth(-1)">
|
||||
<img class="h-6 w-6 opacity-70" src="~/assets/icons/keyboard_arrow_left.svg" alt="">
|
||||
</button>
|
||||
<strong class="text-lg">{{ calendarYear }}년 {{ calendarMonth + 1 }}월</strong>
|
||||
<button type="button" class="flex h-9 w-9 items-center justify-center rounded-md active:bg-paper" aria-label="다음 달" @click="moveMonth(1)">
|
||||
<img class="h-6 w-6 opacity-70" src="~/assets/icons/keyboard_arrow_right.svg" alt="">
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-7 px-4 text-center text-xs font-bold text-muted">
|
||||
<span v-for="day in weekdays" :key="day" class="py-2">{{ day }}</span>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-7 gap-y-1 px-4 pb-6 text-center">
|
||||
<span v-for="blank in firstWeekday" :key="`blank-${blank}`" />
|
||||
<button
|
||||
v-for="day in daysInMonth"
|
||||
:key="day"
|
||||
type="button"
|
||||
class="mx-auto flex h-10 w-10 items-center justify-center rounded-full text-sm font-bold"
|
||||
:class="dateValue(day) === modelValue ? 'bg-ink text-white' : 'active:bg-paper'"
|
||||
@click="select(day)"
|
||||
>
|
||||
{{ day }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { addMonths, format, parse } from 'date-fns'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
}>()
|
||||
|
||||
const weekdays = ['일', '월', '화', '수', '목', '금', '토']
|
||||
const open = ref(false)
|
||||
const calendarDate = ref(parse(props.modelValue, 'yyyy-MM-dd', new Date()))
|
||||
|
||||
const calendarYear = computed(() => calendarDate.value.getFullYear())
|
||||
const calendarMonth = computed(() => calendarDate.value.getMonth())
|
||||
const firstWeekday = computed(() => new Date(calendarYear.value, calendarMonth.value, 1).getDay())
|
||||
const daysInMonth = computed(() => new Date(calendarYear.value, calendarMonth.value + 1, 0).getDate())
|
||||
const displayValue = computed(() => {
|
||||
const date = parse(props.modelValue, 'yyyy-MM-dd', new Date())
|
||||
const weekday = new Intl.DateTimeFormat('ko-KR', { weekday: 'short' }).format(date)
|
||||
return `${format(date, 'yy/M/d')} (${weekday})`
|
||||
})
|
||||
|
||||
watch(open, (value) => {
|
||||
if (value) calendarDate.value = parse(props.modelValue, 'yyyy-MM-dd', new Date())
|
||||
})
|
||||
|
||||
function moveMonth(amount: number) {
|
||||
calendarDate.value = addMonths(calendarDate.value, amount)
|
||||
}
|
||||
|
||||
function dateValue(day: number) {
|
||||
return `${calendarYear.value}-${String(calendarMonth.value + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function select(day: number) {
|
||||
emit('update:modelValue', dateValue(day))
|
||||
open.value = false
|
||||
}
|
||||
</script>
|
||||
@@ -41,7 +41,7 @@ const props = defineProps<{
|
||||
|
||||
const categoryLabel = computed(() => {
|
||||
const category = props.transaction.category
|
||||
return category.parent ? `${category.parent.name} > ${category.name}` : category.name
|
||||
return category.name
|
||||
})
|
||||
|
||||
const categoryInitial = computed(() => {
|
||||
@@ -51,8 +51,7 @@ const categoryInitial = computed(() => {
|
||||
})
|
||||
|
||||
const metaLine = computed(() => {
|
||||
const time = new Intl.DateTimeFormat('ko-KR', { hour: '2-digit', minute: '2-digit' }).format(new Date(props.transaction.occurredAt))
|
||||
return [time, props.transaction.asset.name, categoryLabel.value].filter(Boolean).join(' · ')
|
||||
return [props.transaction.asset.name, categoryLabel.value].filter(Boolean).join(' · ')
|
||||
})
|
||||
|
||||
const memoPreview = computed(() => props.transaction.memo?.trim().slice(0, 60) || '')
|
||||
|
||||
@@ -21,3 +21,8 @@
|
||||
- 입력 중 스크롤이 튀는 현상을 줄이기 위해 바텀 시트 body overflow 변경과 추가 화면 sticky 저장 버튼을 제거했다.
|
||||
- 거래 목록 행을 날짜 그룹, 카테고리 아이콘 자리, 기본 2줄 정보, 메모가 있을 때만 보이는 3번째 줄 구조로 변경했다.
|
||||
- 메모 입력과 저장 길이를 최대 60자로 제한했다.
|
||||
- 거래 입력/수정에서 시간 입력을 제거하고 날짜 바텀 시트 선택으로 변경했다.
|
||||
- 금액 입력 기본값을 제거하고 placeholder로 안내하도록 변경했다.
|
||||
- 거래 목록의 카테고리 표시는 하위 카테고리명 중심으로 단순화했다.
|
||||
- 내역 화면 상단을 헤더, 월 요약, 주간 날짜 스트립 구조로 개편했다.
|
||||
- 방향 이동 버튼을 `assets/icons`의 SVG 화살표로 통일했다.
|
||||
|
||||
@@ -13,9 +13,14 @@
|
||||
- 모바일: 하단 탭, 플로팅 추가 버튼, 바텀 시트 선택 UI를 우선한다.
|
||||
- PC: 사이드바 내비게이션과 넓은 콘텐츠 영역을 사용한다.
|
||||
- 입력 화면은 한 화면에서 빠르게 끝나는 흐름을 유지한다.
|
||||
- 거래 입력에서는 시간 입력을 요구하지 않고 날짜만 선택한다.
|
||||
- 날짜 선택은 모바일 친화적인 바텀 시트 캘린더를 사용한다.
|
||||
- 금액 입력은 기본값 `0`을 채우지 않고 placeholder로만 안내한다.
|
||||
- 셀렉트 박스보다 모바일 친화적인 바텀 시트를 우선한다.
|
||||
- 활성/비활성 같은 상태는 텍스트 버튼보다 토글처럼 현재 상태가 보이는 UI를 쓴다.
|
||||
- 통계 화면은 그래프보다 숫자와 분류별 리스트를 우선한다.
|
||||
- 방향 이동 UI는 `assets/icons`의 키보드 화살표 SVG를 사용한다.
|
||||
- 거래 목록의 카테고리 표시는 하위 카테고리가 있으면 하위 이름만, 없으면 상위 이름만 보여준다.
|
||||
|
||||
## 데이터 기준
|
||||
|
||||
|
||||
@@ -10,8 +10,8 @@
|
||||
|
||||
<div class="grid grid-cols-[1fr_auto] gap-2">
|
||||
<div>
|
||||
<label class="label" for="occurredAt">날짜</label>
|
||||
<input id="occurredAt" v-model="form.occurredAt" class="field" type="datetime-local" required>
|
||||
<label class="label">날짜</label>
|
||||
<BottomSheetDatePicker v-model="form.occurredAt" />
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">반복</span>
|
||||
@@ -41,7 +41,7 @@
|
||||
|
||||
<div>
|
||||
<label class="label" for="amount">금액</label>
|
||||
<input id="amount" v-model.number="form.amount" class="field text-right text-lg font-bold" inputmode="numeric" min="1" type="number" required>
|
||||
<input id="amount" v-model.number="form.amount" class="field text-right text-lg font-bold" inputmode="numeric" min="1" placeholder="0" type="number" required>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -81,10 +81,10 @@ const { data: categoriesData } = await useFetch('/api/categories')
|
||||
|
||||
const form = reactive({
|
||||
type: 'expense',
|
||||
occurredAt: formatDateInput(),
|
||||
occurredAt: formatDateOnly(),
|
||||
assetId: '',
|
||||
categoryId: '',
|
||||
amount: 0,
|
||||
amount: null as number | null,
|
||||
title: '',
|
||||
memo: '',
|
||||
latitude: null as number | null,
|
||||
@@ -101,7 +101,7 @@ const assetOptions = computed(() => assets.value.map((asset: any) => ({
|
||||
})))
|
||||
const categoryOptions = computed(() => filteredCategories.value.map((category: any) => ({
|
||||
value: category.id,
|
||||
label: category.parent ? `${category.parent.name} > ${category.name}` : category.name,
|
||||
label: category.name,
|
||||
description: category.type === 'income' ? '수입' : '지출'
|
||||
})))
|
||||
|
||||
@@ -130,15 +130,15 @@ async function submit() {
|
||||
pending.value = true
|
||||
await $fetch('/api/transactions', {
|
||||
method: 'POST',
|
||||
body: form
|
||||
body: { ...form, occurredAt: dateOnlyToNoon(form.occurredAt) }
|
||||
})
|
||||
const keepAssetId = form.assetId
|
||||
Object.assign(form, {
|
||||
type: form.type,
|
||||
occurredAt: formatDateInput(),
|
||||
occurredAt: formatDateOnly(),
|
||||
assetId: keepAssetId,
|
||||
categoryId: form.categoryId,
|
||||
amount: 0,
|
||||
amount: null,
|
||||
title: '',
|
||||
memo: '',
|
||||
latitude: null,
|
||||
|
||||
@@ -6,20 +6,24 @@
|
||||
<button
|
||||
v-if="selectedAsset"
|
||||
type="button"
|
||||
class="flex h-9 w-9 items-center justify-center rounded-md text-2xl leading-none active:bg-paper"
|
||||
class="flex h-9 w-9 items-center justify-center rounded-md active:bg-paper"
|
||||
aria-label="자산 목록으로"
|
||||
@click="selectedAssetId = ''"
|
||||
>
|
||||
‹
|
||||
<img class="h-6 w-6 opacity-70" src="~/assets/icons/keyboard_arrow_left.svg" alt="">
|
||||
</button>
|
||||
<h1 class="text-lg font-bold">{{ selectedAsset?.name || '자산' }}</h1>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<button class="flex h-9 w-8 items-center justify-center rounded-md text-2xl leading-none active:bg-paper" type="button" @click="moveMonth(-1)">‹</button>
|
||||
<button class="flex h-9 w-8 items-center justify-center rounded-md active:bg-paper" type="button" @click="moveMonth(-1)" aria-label="이전 달">
|
||||
<img class="h-6 w-6 opacity-70" src="~/assets/icons/keyboard_arrow_left.svg" alt="">
|
||||
</button>
|
||||
<button class="rounded-md px-2 py-2 text-base font-semibold active:bg-paper" type="button" @click="monthPickerOpen = true">
|
||||
{{ displayMonth }}
|
||||
</button>
|
||||
<button class="flex h-9 w-8 items-center justify-center rounded-md text-2xl leading-none active:bg-paper" type="button" @click="moveMonth(1)">›</button>
|
||||
<button class="flex h-9 w-8 items-center justify-center rounded-md active:bg-paper" type="button" @click="moveMonth(1)" aria-label="다음 달">
|
||||
<img class="h-6 w-6 opacity-70" src="~/assets/icons/keyboard_arrow_right.svg" alt="">
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -117,9 +121,13 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between px-4 py-4">
|
||||
<button class="text-2xl" type="button" @click="pickerYear--">‹</button>
|
||||
<button class="flex h-9 w-9 items-center justify-center rounded-md active:bg-paper" type="button" @click="pickerYear--" aria-label="이전 년도">
|
||||
<img class="h-6 w-6 opacity-70" src="~/assets/icons/keyboard_arrow_left.svg" alt="">
|
||||
</button>
|
||||
<strong class="text-xl">{{ pickerYear }}</strong>
|
||||
<button class="text-2xl" type="button" @click="pickerYear++">›</button>
|
||||
<button class="flex h-9 w-9 items-center justify-center rounded-md active:bg-paper" type="button" @click="pickerYear++" aria-label="다음 년도">
|
||||
<img class="h-6 w-6 opacity-70" src="~/assets/icons/keyboard_arrow_right.svg" alt="">
|
||||
</button>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 gap-y-2 px-4 pb-6">
|
||||
<button
|
||||
|
||||
171
pages/index.vue
171
pages/index.vue
@@ -1,29 +1,94 @@
|
||||
<template>
|
||||
<section>
|
||||
<header class="sticky top-0 z-10 border-b border-line bg-white px-4 pb-3 pt-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<button class="btn-secondary min-h-10 px-3" @click="moveMonth(-1)">‹</button>
|
||||
<input v-model="selectedMonth" class="w-36 rounded-md border border-line px-3 py-2 text-center font-semibold" type="month">
|
||||
<button class="btn-secondary min-h-10 px-3" @click="moveMonth(1)">›</button>
|
||||
<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-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>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 grid grid-cols-3 gap-2 text-sm">
|
||||
<div>
|
||||
<p class="text-muted">수입</p>
|
||||
<p class="font-bold text-brand-700">{{ formatWon(data?.summary.income || 0) }}</p>
|
||||
|
||||
<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)">
|
||||
<img class="h-6 w-6 opacity-70" src="~/assets/icons/keyboard_arrow_left.svg" alt="">
|
||||
</button>
|
||||
<button type="button" class="text-2xl font-extrabold active:opacity-70" @click="selectedDate = formatDateOnly()">
|
||||
{{ displayMonth }}
|
||||
</button>
|
||||
<button type="button" class="flex h-8 w-8 items-center justify-center rounded-md active:bg-paper" aria-label="다음 달" @click="moveMonth(1)">
|
||||
<img class="h-6 w-6 opacity-70" src="~/assets/icons/keyboard_arrow_right.svg" alt="">
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-muted">지출</p>
|
||||
<p class="font-bold text-red-600">{{ formatWon(data?.summary.expense || 0) }}</p>
|
||||
|
||||
<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">
|
||||
수입 <span class="text-brand-700">{{ formatWon(data?.summary.income || 0) }}</span>
|
||||
· 차액 <span :class="(data?.summary.diff || 0) >= 0 ? 'text-brand-700' : 'text-red-600'">{{ formatWon(data?.summary.diff || 0) }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<NuxtLink to="/stats" class="btn-secondary flex shrink-0 gap-2 px-3 text-xs md:px-4">
|
||||
소비 분류
|
||||
</NuxtLink>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-muted">차액</p>
|
||||
<p class="font-bold">{{ formatWon(data?.summary.diff || 0) }}</p>
|
||||
|
||||
<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="mt-1 grid grid-cols-7 text-center"
|
||||
@touchstart.passive="startWeekSwipe"
|
||||
@touchend.passive="endWeekSwipe"
|
||||
>
|
||||
<button
|
||||
v-for="day in weekDays"
|
||||
:key="day.key"
|
||||
type="button"
|
||||
class="min-h-[4.75rem] rounded-md px-1 py-2"
|
||||
: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"
|
||||
:class="day.key === selectedDate ? 'bg-ink text-white' : ''"
|
||||
>
|
||||
{{ day.day }}
|
||||
</span>
|
||||
<span class="mt-1 block min-h-4 truncate text-[11px] font-bold" :class="day.key === selectedDate ? 'text-ink' : ''">
|
||||
{{ day.amountLabel }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mt-2 grid grid-cols-3 gap-2 rounded-md bg-paper px-3 py-2 text-center text-xs font-semibold">
|
||||
<div>
|
||||
<p class="text-muted">선택일 수입</p>
|
||||
<p class="mt-0.5 text-brand-700">{{ formatWon(selectedDaySummary.income) }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-muted">선택일 지출</p>
|
||||
<p class="mt-0.5 text-red-600">{{ formatWon(selectedDaySummary.expense) }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-muted">합계</p>
|
||||
<p class="mt-0.5">{{ formatWon(selectedDaySummary.diff) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="divide-y divide-line bg-white">
|
||||
<section v-for="group in groupedTransactions" :key="group.date" class="py-2">
|
||||
<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>
|
||||
@@ -42,13 +107,16 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { addMonths, format, parse } from 'date-fns'
|
||||
import { addDays, addMonths, format, parse, startOfWeek } from 'date-fns'
|
||||
import type { Transaction } from '~/composables/useMoney'
|
||||
|
||||
const selectedMonth = ref(monthKey())
|
||||
const selectedDate = ref(formatDateOnly())
|
||||
const weekSwipeStartX = ref(0)
|
||||
const selectedMonth = computed(() => monthKey(parse(selectedDate.value, 'yyyy-MM-dd', new Date())))
|
||||
const { data } = await useFetch('/api/transactions', {
|
||||
query: { month: selectedMonth }
|
||||
})
|
||||
const weekdays = ['일', '월', '화', '수', '목', '금', '토']
|
||||
|
||||
const groupedTransactions = computed(() => {
|
||||
const groups = new Map<string, { date: string; label: string; items: Transaction[] }>()
|
||||
@@ -62,9 +130,37 @@ const groupedTransactions = computed(() => {
|
||||
return Array.from(groups.values()).sort((a, b) => b.date.localeCompare(a.date))
|
||||
})
|
||||
|
||||
function moveMonth(amount: number) {
|
||||
const displayMonth = computed(() => {
|
||||
const date = parse(selectedMonth.value, 'yyyy-MM', new Date())
|
||||
selectedMonth.value = format(addMonths(date, amount), 'yyyy-MM')
|
||||
return format(date, 'M월')
|
||||
})
|
||||
|
||||
const weekDays = computed(() => {
|
||||
const base = parse(selectedDate.value, 'yyyy-MM-dd', new Date())
|
||||
const start = startOfWeek(base, { weekStartsOn: 0 })
|
||||
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,
|
||||
amountLabel: summary.expense ? shortWon(summary.expense) : summary.income ? shortWon(summary.income) : ''
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const selectedDaySummary = computed(() => daySummary(selectedDate.value))
|
||||
|
||||
function moveMonth(amount: number) {
|
||||
const date = parse(selectedDate.value, 'yyyy-MM-dd', new Date())
|
||||
selectedDate.value = format(addMonths(date, amount), 'yyyy-MM-dd')
|
||||
}
|
||||
|
||||
function moveWeek(amount: number) {
|
||||
const date = parse(selectedDate.value, 'yyyy-MM-dd', new Date())
|
||||
selectedDate.value = format(addDays(date, amount * 7), 'yyyy-MM-dd')
|
||||
}
|
||||
|
||||
function dateLabel(date: Date) {
|
||||
@@ -75,4 +171,39 @@ function dateLabel(date: 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 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 goBack() {
|
||||
if (history.length > 1) history.back()
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -5,11 +5,11 @@
|
||||
<button
|
||||
v-if="view !== 'home'"
|
||||
type="button"
|
||||
class="flex h-9 w-9 items-center justify-center rounded-md text-2xl leading-none active:bg-paper"
|
||||
class="flex h-9 w-9 items-center justify-center rounded-md active:bg-paper"
|
||||
aria-label="뒤로"
|
||||
@click="goBack"
|
||||
>
|
||||
‹
|
||||
<img class="h-6 w-6 opacity-70" src="~/assets/icons/keyboard_arrow_left.svg" alt="">
|
||||
</button>
|
||||
<h1 class="text-lg font-bold">{{ title }}</h1>
|
||||
</div>
|
||||
@@ -39,7 +39,7 @@
|
||||
<span class="block text-base font-semibold text-ink">{{ item.label }}</span>
|
||||
<span class="mt-1 block text-xs text-muted">{{ item.description }}</span>
|
||||
</span>
|
||||
<span class="text-xl text-muted">›</span>
|
||||
<img class="h-6 w-6 opacity-60" src="~/assets/icons/keyboard_arrow_right.svg" alt="">
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -130,7 +130,7 @@
|
||||
<span class="block text-base font-semibold text-ink">{{ category.name }}</span>
|
||||
<span class="mt-1 block text-xs text-muted">{{ categoryDescription(category) }}</span>
|
||||
</span>
|
||||
<span v-if="!selectedParentCategory" class="text-xl text-muted">›</span>
|
||||
<img v-if="!selectedParentCategory" class="h-6 w-6 opacity-60" src="~/assets/icons/keyboard_arrow_right.svg" alt="">
|
||||
</button>
|
||||
|
||||
<p v-if="!visibleCategories.length" class="px-4 py-12 text-center text-muted">
|
||||
|
||||
@@ -3,11 +3,15 @@
|
||||
<header class="sticky top-0 z-10 border-b border-line bg-white">
|
||||
<div class="flex h-14 items-center justify-between px-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<button class="flex h-9 w-9 items-center justify-center rounded-md text-2xl leading-none active:bg-paper" type="button" @click="moveMonth(-1)">‹</button>
|
||||
<button class="flex h-9 w-9 items-center justify-center rounded-md active:bg-paper" type="button" @click="moveMonth(-1)" aria-label="이전 달">
|
||||
<img class="h-6 w-6 opacity-70" src="~/assets/icons/keyboard_arrow_left.svg" alt="">
|
||||
</button>
|
||||
<button class="rounded-md px-1 py-2 text-left text-lg font-semibold active:bg-paper" type="button" @click="monthPickerOpen = true">
|
||||
{{ displayMonth }}
|
||||
</button>
|
||||
<button class="flex h-9 w-9 items-center justify-center rounded-md text-2xl leading-none active:bg-paper" type="button" @click="moveMonth(1)">›</button>
|
||||
<button class="flex h-9 w-9 items-center justify-center rounded-md active:bg-paper" type="button" @click="moveMonth(1)" aria-label="다음 달">
|
||||
<img class="h-6 w-6 opacity-70" src="~/assets/icons/keyboard_arrow_right.svg" alt="">
|
||||
</button>
|
||||
</div>
|
||||
<button class="btn-secondary min-h-10 px-3 text-base" type="button" @click="periodSheetOpen = true">
|
||||
월간 ⌄
|
||||
@@ -93,9 +97,13 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between px-4 py-4">
|
||||
<button class="text-2xl" type="button" @click="pickerYear--">‹</button>
|
||||
<button class="flex h-9 w-9 items-center justify-center rounded-md active:bg-paper" type="button" @click="pickerYear--" aria-label="이전 년도">
|
||||
<img class="h-6 w-6 opacity-70" src="~/assets/icons/keyboard_arrow_left.svg" alt="">
|
||||
</button>
|
||||
<strong class="text-xl">{{ pickerYear }}</strong>
|
||||
<button class="text-2xl" type="button" @click="pickerYear++">›</button>
|
||||
<button class="flex h-9 w-9 items-center justify-center rounded-md active:bg-paper" type="button" @click="pickerYear++" aria-label="다음 년도">
|
||||
<img class="h-6 w-6 opacity-70" src="~/assets/icons/keyboard_arrow_right.svg" alt="">
|
||||
</button>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 gap-y-2 px-4 pb-6">
|
||||
<button
|
||||
|
||||
@@ -4,11 +4,11 @@
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-9 w-9 items-center justify-center rounded-md text-2xl leading-none active:bg-paper"
|
||||
class="flex h-9 w-9 items-center justify-center rounded-md active:bg-paper"
|
||||
aria-label="뒤로"
|
||||
@click="goBack"
|
||||
>
|
||||
‹
|
||||
<img class="h-6 w-6 opacity-70" src="~/assets/icons/keyboard_arrow_left.svg" alt="">
|
||||
</button>
|
||||
<h1 class="text-lg font-bold">{{ form.type === 'income' ? '수입' : '지출' }}</h1>
|
||||
</div>
|
||||
@@ -25,8 +25,8 @@
|
||||
|
||||
<div class="divide-y divide-line border-y border-line">
|
||||
<div class="grid grid-cols-[4.25rem_1fr] items-center gap-3 py-3">
|
||||
<label class="text-sm font-semibold text-muted" for="occurredAt">날짜</label>
|
||||
<input id="occurredAt" v-model="form.occurredAt" class="field border-0 bg-transparent shadow-none focus:ring-0" type="datetime-local" required>
|
||||
<span class="text-sm font-semibold text-muted">날짜</span>
|
||||
<BottomSheetDatePicker v-model="form.occurredAt" />
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-[4.25rem_1fr] items-center gap-3 py-3">
|
||||
@@ -51,7 +51,7 @@
|
||||
|
||||
<div class="grid grid-cols-[4.25rem_1fr] items-center gap-3 py-3">
|
||||
<label class="text-sm font-semibold text-muted" for="amount">금액</label>
|
||||
<input id="amount" v-model.number="form.amount" class="field border-0 bg-transparent text-right text-lg font-bold shadow-none focus:ring-0" inputmode="numeric" min="1" type="number" required>
|
||||
<input id="amount" v-model.number="form.amount" class="field border-0 bg-transparent text-right text-lg font-bold shadow-none focus:ring-0" inputmode="numeric" min="1" placeholder="0" type="number" required>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-[4.25rem_1fr] items-center gap-3 py-3">
|
||||
@@ -96,7 +96,7 @@ const { data: categoriesData } = await useFetch('/api/categories')
|
||||
|
||||
const form = reactive({
|
||||
type: 'expense' as TransactionType,
|
||||
occurredAt: formatDateInput(),
|
||||
occurredAt: formatDateOnly(),
|
||||
assetId: '',
|
||||
categoryId: '',
|
||||
amount: 0,
|
||||
@@ -116,7 +116,7 @@ const assetOptions = computed(() => assets.value.map((asset: any) => ({
|
||||
})))
|
||||
const categoryOptions = computed(() => filteredCategories.value.map((category: any) => ({
|
||||
value: category.id,
|
||||
label: category.parent ? `${category.parent.name} > ${category.name}` : category.name,
|
||||
label: category.name,
|
||||
description: category.type === 'income' ? '수입' : '지출'
|
||||
})))
|
||||
const locationLabel = computed(() => form.latitude && form.longitude)
|
||||
@@ -126,7 +126,7 @@ watch(transaction, (value) => {
|
||||
if (!value) return
|
||||
Object.assign(form, {
|
||||
type: value.type,
|
||||
occurredAt: formatDateInput(new Date(value.occurredAt)),
|
||||
occurredAt: formatDateOnly(new Date(value.occurredAt)),
|
||||
assetId: value.assetId,
|
||||
categoryId: value.categoryId,
|
||||
amount: value.amount,
|
||||
@@ -153,7 +153,7 @@ function clearLocation() {
|
||||
function payload() {
|
||||
return {
|
||||
type: form.type,
|
||||
occurredAt: form.occurredAt,
|
||||
occurredAt: dateOnlyToNoon(form.occurredAt),
|
||||
assetId: form.assetId,
|
||||
categoryId: form.categoryId,
|
||||
amount: form.amount,
|
||||
@@ -184,7 +184,7 @@ async function copy() {
|
||||
pending.value = true
|
||||
const created = await $fetch<any>('/api/transactions', {
|
||||
method: 'POST',
|
||||
body: { ...payload(), occurredAt: formatDateInput() }
|
||||
body: { ...payload(), occurredAt: dateOnlyToNoon(formatDateOnly()) }
|
||||
})
|
||||
pending.value = false
|
||||
await navigateTo(`/transactions/${created.id}`)
|
||||
|
||||
@@ -7,6 +7,15 @@ export function formatDateInput(date = new Date()) {
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`
|
||||
}
|
||||
|
||||
export function formatDateOnly(date = new Date()) {
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
|
||||
}
|
||||
|
||||
export function dateOnlyToNoon(value: string) {
|
||||
return `${value}T12:00`
|
||||
}
|
||||
|
||||
export function monthKey(date = new Date()) {
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}`
|
||||
|
||||
Reference in New Issue
Block a user