Refine ledger motion and transaction form
This commit is contained in:
@@ -48,3 +48,5 @@
|
||||
- README에 개발, 빌드, Prisma 관련 주요 명령과 현재 검증 기준을 정리했다.
|
||||
- TODO에서 요구사항과 맞지 않던 이체 관련 작업 항목을 제거했다.
|
||||
- 설정의 계정 관리 화면에서 이름 수정과 비밀번호 변경 기능을 구현했다.
|
||||
- 내역 화면의 주간 날짜 스트립에 좌우 슬라이드 전환 애니메이션을 추가했다.
|
||||
- 거래 입력/수정 화면의 자산, 분류, 금액, 내용 placeholder를 제거하고 메모/위치 영역을 같은 입력 리스트 안의 얇은 행으로 정리했다.
|
||||
|
||||
@@ -16,7 +16,8 @@
|
||||
- 거래 입력에서는 시간 입력을 요구하지 않고 날짜만 선택한다.
|
||||
- 거래 타입은 수입과 지출만 사용하고 이체 타입이나 이체 탭은 만들지 않는다.
|
||||
- 날짜 선택은 모바일 친화적인 바텀 시트 캘린더를 사용한다.
|
||||
- 금액 입력은 기본값 `0`을 채우지 않고 placeholder로만 안내한다.
|
||||
- 자산, 분류, 금액, 내용 입력은 placeholder 문구를 넣지 않고 비어 있는 입력 상태로 둔다.
|
||||
- 금액 입력은 기본값 `0`을 채우지 않는다.
|
||||
- 셀렉트 박스보다 모바일 친화적인 바텀 시트를 우선한다.
|
||||
- 활성/비활성 같은 상태는 텍스트 버튼보다 토글처럼 현재 상태가 보이는 UI를 쓴다.
|
||||
- 통계 화면은 그래프보다 숫자와 분류별 리스트를 우선한다.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user