Refine transaction list rows
This commit is contained in:
59
components/TransactionListRow.vue
Normal file
59
components/TransactionListRow.vue
Normal file
@@ -0,0 +1,59 @@
|
||||
<template>
|
||||
<NuxtLink
|
||||
:to="`/transactions/${transaction.id}`"
|
||||
class="grid grid-cols-[2.5rem_minmax(0,1fr)_auto] items-start gap-3 px-4 py-3 active:bg-paper md:px-5"
|
||||
>
|
||||
<div
|
||||
class="mt-0.5 flex h-10 w-10 items-center justify-center rounded-md bg-paper text-sm font-bold"
|
||||
:class="transaction.type === 'income' ? 'text-brand-700' : 'text-muted'"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{{ categoryInitial }}
|
||||
</div>
|
||||
|
||||
<div class="min-w-0">
|
||||
<p class="truncate text-[15px] font-bold leading-5 text-ink">
|
||||
{{ transaction.title }}
|
||||
</p>
|
||||
<p class="mt-1 truncate text-xs font-medium leading-5 text-muted">
|
||||
{{ metaLine }}
|
||||
</p>
|
||||
<p v-if="memoPreview" class="mt-1 truncate text-xs font-medium leading-5 text-muted">
|
||||
{{ memoPreview }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p
|
||||
class="whitespace-nowrap pt-0.5 text-right text-[15px] font-bold leading-5"
|
||||
:class="transaction.type === 'income' ? 'text-brand-700' : 'text-ink'"
|
||||
>
|
||||
{{ formatWon(transaction.amount) }}
|
||||
</p>
|
||||
</NuxtLink>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Transaction } from '~/composables/useMoney'
|
||||
|
||||
const props = defineProps<{
|
||||
transaction: Transaction
|
||||
}>()
|
||||
|
||||
const categoryLabel = computed(() => {
|
||||
const category = props.transaction.category
|
||||
return category.parent ? `${category.parent.name} > ${category.name}` : category.name
|
||||
})
|
||||
|
||||
const categoryInitial = computed(() => {
|
||||
const category = props.transaction.category
|
||||
const name = category.parent?.name || category.name || (props.transaction.type === 'income' ? '수' : '지')
|
||||
return name.slice(0, 1)
|
||||
})
|
||||
|
||||
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(' · ')
|
||||
})
|
||||
|
||||
const memoPreview = computed(() => props.transaction.memo?.trim().slice(0, 60) || '')
|
||||
</script>
|
||||
@@ -19,3 +19,5 @@
|
||||
- 바텀 시트 선택 버튼에서 하단 화살표를 제거했다.
|
||||
- 로그인 화면이 하단 탭 여백 때문에 스크롤되는 문제를 수정했다.
|
||||
- 입력 중 스크롤이 튀는 현상을 줄이기 위해 바텀 시트 body overflow 변경과 추가 화면 sticky 저장 버튼을 제거했다.
|
||||
- 거래 목록 행을 날짜 그룹, 카테고리 아이콘 자리, 기본 2줄 정보, 메모가 있을 때만 보이는 3번째 줄 구조로 변경했다.
|
||||
- 메모 입력과 저장 길이를 최대 60자로 제한했다.
|
||||
|
||||
@@ -64,7 +64,7 @@
|
||||
|
||||
<div>
|
||||
<label class="label" for="memo">메모</label>
|
||||
<textarea id="memo" v-model="form.memo" class="field min-h-24 resize-none" placeholder="자유롭게 기록"></textarea>
|
||||
<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">
|
||||
|
||||
@@ -90,23 +90,11 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<NuxtLink
|
||||
<TransactionListRow
|
||||
v-for="tx in group.items"
|
||||
:key="tx.id"
|
||||
:to="`/transactions/${tx.id}`"
|
||||
class="grid grid-cols-[1fr_auto] gap-3 px-4 py-3"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<p class="truncate font-semibold">{{ tx.title }}</p>
|
||||
<p class="mt-1 truncate text-sm text-muted">{{ categoryLabel(tx.category) }}</p>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<p class="font-bold" :class="tx.type === 'income' ? 'text-brand-700' : 'text-red-600'">
|
||||
{{ formatWon(tx.amount) }}
|
||||
</p>
|
||||
<p class="mt-1 text-sm text-muted">{{ timeLabel(tx.occurredAt) }}</p>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
:transaction="tx"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<p v-if="!assetTransactions.length" class="px-4 py-12 text-center text-muted">
|
||||
@@ -153,7 +141,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { addMonths, format, parse } from 'date-fns'
|
||||
import type { Category, Transaction } from '~/composables/useMoney'
|
||||
import type { Transaction } from '~/composables/useMoney'
|
||||
|
||||
const selectedMonth = ref(monthKey())
|
||||
const selectedAssetId = ref('')
|
||||
@@ -220,11 +208,4 @@ function selectPickerMonth(month: number) {
|
||||
monthPickerOpen.value = false
|
||||
}
|
||||
|
||||
function timeLabel(value: string) {
|
||||
return new Intl.DateTimeFormat('ko-KR', { hour: '2-digit', minute: '2-digit' }).format(new Date(value))
|
||||
}
|
||||
|
||||
function categoryLabel(category: Category & { parent?: Category | null }) {
|
||||
return category.parent ? `${category.parent.name} > ${category.name}` : category.name
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -22,28 +22,17 @@
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="divide-y divide-line">
|
||||
<NuxtLink
|
||||
v-for="tx in data?.transactions"
|
||||
:key="tx.id"
|
||||
:to="`/transactions/${tx.id}`"
|
||||
class="grid grid-cols-[1fr_auto] gap-3 px-4 py-3"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<p class="truncate font-semibold text-ink">{{ tx.title }}</p>
|
||||
<p class="mt-1 text-xs text-muted">
|
||||
{{ timeLabel(tx.occurredAt) }} · {{ tx.asset.name }}
|
||||
</p>
|
||||
<p class="mt-1 truncate text-xs text-muted">
|
||||
{{ categoryLabel(tx.category) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<p class="font-bold" :class="tx.type === 'income' ? 'text-brand-700' : 'text-red-600'">
|
||||
{{ tx.type === 'income' ? '+' : '-' }}{{ formatWon(tx.amount) }}
|
||||
</p>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
<div class="divide-y divide-line bg-white">
|
||||
<section v-for="group in groupedTransactions" :key="group.date" class="py-2">
|
||||
<h2 class="px-4 py-3 text-sm font-semibold text-muted md:px-5">
|
||||
{{ group.label }}
|
||||
</h2>
|
||||
<TransactionListRow
|
||||
v-for="tx in group.items"
|
||||
:key="tx.id"
|
||||
:transaction="tx"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div v-if="!data?.transactions.length" class="px-4 py-16 text-center text-muted">
|
||||
@@ -54,24 +43,36 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { addMonths, format, parse } from 'date-fns'
|
||||
import type { Category } from '~/composables/useMoney'
|
||||
import type { Transaction } from '~/composables/useMoney'
|
||||
|
||||
const selectedMonth = ref(monthKey())
|
||||
const { data } = await useFetch('/api/transactions', {
|
||||
query: { month: selectedMonth }
|
||||
})
|
||||
|
||||
const groupedTransactions = computed(() => {
|
||||
const groups = new Map<string, { date: string; label: string; items: Transaction[] }>()
|
||||
for (const tx of data.value?.transactions || []) {
|
||||
const date = new Date(tx.occurredAt)
|
||||
const key = format(date, 'yyyy-MM-dd')
|
||||
const group = groups.get(key) || { date: key, label: dateLabel(date), items: [] }
|
||||
group.items.push(tx)
|
||||
groups.set(key, group)
|
||||
}
|
||||
return Array.from(groups.values()).sort((a, b) => b.date.localeCompare(a.date))
|
||||
})
|
||||
|
||||
function moveMonth(amount: number) {
|
||||
const date = parse(selectedMonth.value, 'yyyy-MM', new Date())
|
||||
selectedMonth.value = format(addMonths(date, amount), 'yyyy-MM')
|
||||
}
|
||||
|
||||
function timeLabel(value: string) {
|
||||
return new Intl.DateTimeFormat('ko-KR', { hour: '2-digit', minute: '2-digit' }).format(new Date(value))
|
||||
function dateLabel(date: Date) {
|
||||
const key = format(date, 'yyyy-MM-dd')
|
||||
const today = format(new Date(), 'yyyy-MM-dd')
|
||||
const yesterday = format(new Date(Date.now() - 24 * 60 * 60 * 1000), 'yyyy-MM-dd')
|
||||
const weekday = new Intl.DateTimeFormat('ko-KR', { weekday: 'short' }).format(date)
|
||||
const relative = key === today ? ' · 오늘' : key === yesterday ? ' · 어제' : ''
|
||||
return `${format(date, 'M. d')}(${weekday})${relative}`
|
||||
}
|
||||
|
||||
function categoryLabel(category: Category & { parent?: Category | null }) {
|
||||
return category.parent ? `${category.parent.name} > ${category.name}` : category.name
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
@@ -62,7 +62,7 @@
|
||||
|
||||
<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" placeholder="메모"></textarea>
|
||||
<textarea id="memo" v-model="form.memo" class="field min-h-24 resize-none" maxlength="60" placeholder="메모"></textarea>
|
||||
</div>
|
||||
|
||||
<div v-if="locationLabel" class="mt-3 flex items-center justify-between border-b border-line py-4">
|
||||
|
||||
@@ -42,7 +42,7 @@ export default defineEventHandler(async (event) => {
|
||||
categoryId: body.categoryId,
|
||||
amount: Number(body.amount),
|
||||
title: body.title.trim(),
|
||||
memo: body.memo?.trim() || null,
|
||||
memo: body.memo?.trim().slice(0, 60) || null,
|
||||
latitude: body.latitude ?? null,
|
||||
longitude: body.longitude ?? null
|
||||
},
|
||||
|
||||
@@ -38,7 +38,7 @@ export default defineEventHandler(async (event) => {
|
||||
categoryId: body.categoryId,
|
||||
amount: Number(body.amount),
|
||||
title: body.title.trim(),
|
||||
memo: body.memo?.trim() || null,
|
||||
memo: body.memo?.trim().slice(0, 60) || null,
|
||||
latitude: body.latitude ?? null,
|
||||
longitude: body.longitude ?? null
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user