Refine ledger motion and transaction form

This commit is contained in:
2026-08-13 18:25:29 +09:00
parent 6f54d09dde
commit 779b86b748
6 changed files with 107 additions and 55 deletions

View File

@@ -48,3 +48,5 @@
- README에 개발, 빌드, Prisma 관련 주요 명령과 현재 검증 기준을 정리했다.
- TODO에서 요구사항과 맞지 않던 이체 관련 작업 항목을 제거했다.
- 설정의 계정 관리 화면에서 이름 수정과 비밀번호 변경 기능을 구현했다.
- 내역 화면의 주간 날짜 스트립에 좌우 슬라이드 전환 애니메이션을 추가했다.
- 거래 입력/수정 화면의 자산, 분류, 금액, 내용 placeholder를 제거하고 메모/위치 영역을 같은 입력 리스트 안의 얇은 행으로 정리했다.

View File

@@ -16,7 +16,8 @@
- 거래 입력에서는 시간 입력을 요구하지 않고 날짜만 선택한다.
- 거래 타입은 수입과 지출만 사용하고 이체 타입이나 이체 탭은 만들지 않는다.
- 날짜 선택은 모바일 친화적인 바텀 시트 캘린더를 사용한다.
- 금액 입력은 기본값 `0`을 채우지 않고 placeholder로만 안내한다.
- 자산, 분류, 금액, 내용 입력은 placeholder 문구를 넣지 않고 비어 있는 입력 상태로 둔다.
- 금액 입력은 기본값 `0`을 채우지 않는다.
- 셀렉트 박스보다 모바일 친화적인 바텀 시트를 우선한다.
- 활성/비활성 같은 상태는 텍스트 버튼보다 토글처럼 현재 상태가 보이는 UI를 쓴다.
- 통계 화면은 그래프보다 숫자와 분류별 리스트를 우선한다.

View File

@@ -34,7 +34,7 @@
<BottomSheetSelect
v-model="form.assetId"
title="자산 선택"
placeholder="자산 선택"
placeholder=""
:options="assetOptions"
/>
</div>
@@ -44,41 +44,39 @@
<BottomSheetSelect
v-model="form.categoryId"
title="분류 선택"
placeholder="분류 선택"
placeholder=""
:options="categoryOptions"
/>
</div>
<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" placeholder="0" 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" type="number" required>
</div>
<div class="grid grid-cols-[4.25rem_1fr] items-center gap-3 py-3">
<label class="text-sm font-semibold text-muted" for="title">내용</label>
<input id="title" v-model="form.title" class="field border-0 bg-transparent font-semibold shadow-none focus:ring-0" placeholder="이마트칠성점" required>
<input id="title" v-model="form.title" class="field border-0 bg-transparent font-semibold shadow-none focus:ring-0" required>
</div>
</div>
<div class="mt-4 border-y border-line py-4">
<div class="mb-1 flex items-center justify-between">
<div class="grid grid-cols-[4.25rem_1fr] items-start gap-3 py-3">
<label class="pt-2 text-sm font-semibold text-muted" for="memo">메모</label>
<textarea id="memo" v-model="form.memo" class="field min-h-12 resize-none border-0 bg-transparent shadow-none focus:ring-0" maxlength="60"></textarea>
</div>
<div class="grid grid-cols-[4.25rem_1fr] items-center gap-3 py-3">
<span class="text-sm font-semibold text-muted">위치</span>
<div class="flex gap-2">
<div class="flex min-h-10 items-center justify-end gap-3">
<span v-if="form.latitude" class="truncate text-right text-sm font-semibold text-muted">
{{ form.latitude }}, {{ form.longitude }}
</span>
<button type="button" class="text-sm font-semibold text-brand-700" @click="captureLocation">가져오기</button>
<button v-if="form.latitude" type="button" class="text-sm font-semibold text-muted" @click="clearLocation">삭제</button>
</div>
</div>
<p class="mt-2 rounded-md bg-paper px-4 py-2.5 text-sm text-muted">
{{ form.latitude ? `${form.latitude}, ${form.longitude}` : '선택 사항입니다.' }}
</p>
</div>
<div class="mt-4 border-y border-line py-4">
<label class="mb-2 block text-sm font-semibold text-muted" for="memo">메모</label>
<textarea id="memo" v-model="form.memo" class="field min-h-24 resize-none" maxlength="60" placeholder="자유롭게 기록"></textarea>
</div>
<button class="btn-primary mt-5 w-full" type="submit" :disabled="pending">
<button class="btn-primary mt-5 w-full" type="submit" :disabled="pending || !canSubmit">
{{ pending ? '저장 중...' : '저장하기' }}
</button>
</form>
@@ -123,13 +121,13 @@ const categoryOptions = computed(() => selectableCategories.value.map((category:
description: category.parent?.name || (category.type === 'income' ? '수입' : '지출')
})))
watch(assets, (items: any[]) => {
if (!form.assetId && items[0]) form.assetId = items[0].id
watch(selectableCategories, (items: any[]) => {
if (form.categoryId && !items.some(item => item.id === form.categoryId)) form.categoryId = ''
}, { immediate: true })
watch(selectableCategories, (items: any[]) => {
if (!items.some(item => item.id === form.categoryId)) form.categoryId = items[0]?.id || ''
}, { immediate: true })
const canSubmit = computed(() => {
return Boolean(form.assetId && form.categoryId && form.amount && form.amount > 0 && form.title.trim())
})
function setType(type: TransactionType) {
form.type = type

View File

@@ -56,29 +56,36 @@
<span v-for="day in weekdays" :key="day" class="py-1">{{ day }}</span>
</div>
<div
class="mt-1 grid grid-cols-7 text-center"
class="relative mt-1 min-h-[4.85rem] overflow-hidden"
@touchstart.passive="startWeekSwipe"
@touchend.passive="endWeekSwipe"
>
<button
v-for="day in weekDays"
:key="day.key"
type="button"
class="min-h-[4.35rem] 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)"
<Transition
:name="weekTransitionName"
mode="out-in"
>
<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-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 :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>
@@ -183,6 +190,7 @@ import type { Transaction } from '~/composables/useMoney'
const selectedDate = ref(formatDateOnly())
const weekSwipeStartX = ref(0)
const weekDirection = ref<'next' | 'prev'>('next')
const searchOpen = ref(false)
const filterSheetOpen = ref(false)
const monthPickerOpen = ref(false)
@@ -193,9 +201,13 @@ const filters = reactive({
categoryId: ''
})
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: 0 }), '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`
}
})
@@ -287,11 +299,13 @@ watch(() => filters.type, () => {
})
function moveMonth(amount: number) {
weekDirection.value = amount > 0 ? 'next' : 'prev'
const date = parse(selectedDate.value, 'yyyy-MM-dd', new Date())
selectedDate.value = format(addMonths(date, amount), 'yyyy-MM-dd')
}
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')
}
@@ -352,3 +366,32 @@ 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>

View File

@@ -34,7 +34,7 @@
<BottomSheetSelect
v-model="form.assetId"
title="자산 선택"
placeholder="자산 선택"
placeholder=""
:options="assetOptions"
/>
</div>
@@ -44,30 +44,33 @@
<BottomSheetSelect
v-model="form.categoryId"
title="분류 선택"
placeholder="분류 선택"
placeholder=""
:options="categoryOptions"
/>
</div>
<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" placeholder="0" 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" type="number" required>
</div>
<div class="grid grid-cols-[4.25rem_1fr] items-center gap-3 py-3">
<label class="text-sm font-semibold text-muted" for="title">내용</label>
<input id="title" v-model="form.title" class="field border-0 bg-transparent font-semibold shadow-none focus:ring-0" required>
</div>
</div>
<div class="mt-4 border-y border-line py-4">
<label class="mb-2 block text-sm font-semibold text-muted" for="memo">메모</label>
<textarea id="memo" v-model="form.memo" class="field min-h-24 resize-none" maxlength="60" placeholder="메모"></textarea>
</div>
<div class="grid grid-cols-[4.25rem_1fr] items-start gap-3 py-3">
<label class="pt-2 text-sm font-semibold text-muted" for="memo">메모</label>
<textarea id="memo" v-model="form.memo" class="field min-h-12 resize-none border-0 bg-transparent shadow-none focus:ring-0" maxlength="60"></textarea>
</div>
<div v-if="locationLabel" class="mt-3 flex items-center justify-between border-b border-line py-4">
<a class="font-semibold text-brand-700" :href="mapsUrl" target="_blank" rel="noreferrer">위치 보기</a>
<button type="button" class="text-sm font-semibold text-muted" @click="clearLocation">위치 삭제</button>
<div v-if="locationLabel" class="grid grid-cols-[4.25rem_1fr] items-center gap-3 py-3">
<span class="text-sm font-semibold text-muted">위치</span>
<div class="flex items-center justify-end gap-3">
<a class="text-sm font-semibold text-brand-700" :href="mapsUrl" target="_blank" rel="noreferrer">보기</a>
<button type="button" class="text-sm font-semibold text-muted" @click="clearLocation">삭제</button>
</div>
</div>
</div>
<div class="mt-6 grid grid-cols-3 gap-2">
@@ -76,7 +79,7 @@
<button type="button" class="btn-secondary gap-2" disabled>자주사용</button>
</div>
<button class="btn-primary mt-6 w-full" type="submit" :disabled="pending">
<button class="btn-primary mt-6 w-full" type="submit" :disabled="pending || !canSubmit">
{{ pending ? '저장 중...' : '저장하기' }}
</button>
</form>
@@ -142,7 +145,11 @@ watch(transaction, (value) => {
}, { immediate: true })
watch(selectableCategories, (items: any[]) => {
if (!items.some(item => item.id === form.categoryId)) form.categoryId = items[0]?.id || ''
if (form.categoryId && !items.some(item => item.id === form.categoryId)) form.categoryId = ''
})
const canSubmit = computed(() => {
return Boolean(form.assetId && form.categoryId && form.amount && form.amount > 0 && form.title.trim())
})
function setType(type: TransactionType) {

View File

@@ -8,6 +8,7 @@
- 내역, 통계, 자산 페이지의 월 표시 위치와 좌우 이동 버튼 위치를 맞춘다.
- PC 화면에서 각 페이지가 너무 모바일 화면처럼 보이지 않도록 표/패널 밀도를 조정한다.
- 모바일과 PC에서 같은 기능이 서로 다른 위치에 흩어지지 않도록 액션 버튼 배치를 정리한다.
- 화면 전환, 날짜 이동, 바텀 시트 표시처럼 사용자가 조작 결과를 바로 느껴야 하는 부분에 모션 피드백을 추가한다.
## 2. 내역 화면