Refine transaction date input and header

This commit is contained in:
2026-08-13 16:57:27 +09:00
parent cecd390b33
commit 90e8b48c2b
15 changed files with 346 additions and 56 deletions

View 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

View 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

View 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

View 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

View 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>

View File

@@ -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) || '')

View File

@@ -21,3 +21,8 @@
- 입력 중 스크롤이 튀는 현상을 줄이기 위해 바텀 시트 body overflow 변경과 추가 화면 sticky 저장 버튼을 제거했다.
- 거래 목록 행을 날짜 그룹, 카테고리 아이콘 자리, 기본 2줄 정보, 메모가 있을 때만 보이는 3번째 줄 구조로 변경했다.
- 메모 입력과 저장 길이를 최대 60자로 제한했다.
- 거래 입력/수정에서 시간 입력을 제거하고 날짜 바텀 시트 선택으로 변경했다.
- 금액 입력 기본값을 제거하고 placeholder로 안내하도록 변경했다.
- 거래 목록의 카테고리 표시는 하위 카테고리명 중심으로 단순화했다.
- 내역 화면 상단을 헤더, 월 요약, 주간 날짜 스트립 구조로 개편했다.
- 방향 이동 버튼을 `assets/icons`의 SVG 화살표로 통일했다.

View File

@@ -13,9 +13,14 @@
- 모바일: 하단 탭, 플로팅 추가 버튼, 바텀 시트 선택 UI를 우선한다.
- PC: 사이드바 내비게이션과 넓은 콘텐츠 영역을 사용한다.
- 입력 화면은 한 화면에서 빠르게 끝나는 흐름을 유지한다.
- 거래 입력에서는 시간 입력을 요구하지 않고 날짜만 선택한다.
- 날짜 선택은 모바일 친화적인 바텀 시트 캘린더를 사용한다.
- 금액 입력은 기본값 `0`을 채우지 않고 placeholder로만 안내한다.
- 셀렉트 박스보다 모바일 친화적인 바텀 시트를 우선한다.
- 활성/비활성 같은 상태는 텍스트 버튼보다 토글처럼 현재 상태가 보이는 UI를 쓴다.
- 통계 화면은 그래프보다 숫자와 분류별 리스트를 우선한다.
- 방향 이동 UI는 `assets/icons`의 키보드 화살표 SVG를 사용한다.
- 거래 목록의 카테고리 표시는 하위 카테고리가 있으면 하위 이름만, 없으면 상위 이름만 보여준다.
## 데이터 기준

View File

@@ -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,

View File

@@ -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

View File

@@ -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>

View File

@@ -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">

View File

@@ -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

View File

@@ -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}`)

View File

@@ -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)}`