Skip to content

Commit 3024cd6

Browse files
authored
[개선] 거래 항목 위계 개선 및 상세 배지 헤더 이동 (#121)
* feat: 거래 항목 위계 개선 및 상세 배지 위치 조정 (#120) * refactor: 거래 타입/금액 표시 규칙 공용 유틸로 통합
1 parent b894cf3 commit 3024cd6

3 files changed

Lines changed: 92 additions & 124 deletions

File tree

apps/web/src/lib/format.ts

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ export function formatTransactionType(type: string): string {
77
INCOME: '수입',
88
EXPENSE: '지출',
99
TRANSFER: '이체',
10-
DEBT_PAYMENT: '부채상환',
10+
DEBT_PAYMENT: '부채 상환',
1111
SAVING: '저축',
1212
}
1313
return typeMap[type] ?? type
@@ -19,8 +19,18 @@ export function getTransactionSign(type: string): '+' | '-' | '' {
1919
return ''
2020
}
2121

22-
export function getAmountColorClass(type: string): string {
23-
if (type === 'INCOME') return 'text-blue-600 dark:text-blue-400'
24-
if (type === 'EXPENSE') return 'text-rose-600 dark:text-rose-400'
22+
export function formatSignedAmount(type: string, amount: number): string {
23+
return `${getTransactionSign(type)}${formatCurrency(amount)}원`
24+
}
25+
26+
export function getAmountColorClass(type: string, tone: 'default' | 'semantic' = 'default'): string {
27+
if (type === 'INCOME') return tone === 'semantic' ? 'text-blue-600' : 'text-blue-600 dark:text-blue-400'
28+
if (type === 'EXPENSE') return tone === 'semantic' ? 'text-destructive' : 'text-rose-600 dark:text-rose-400'
2529
return 'text-muted-foreground'
2630
}
31+
32+
export function getTransactionBadgeClass(type: string): string {
33+
if (type === 'INCOME') return 'bg-blue-100 text-blue-800'
34+
if (type === 'EXPENSE') return 'bg-destructive/10 text-destructive'
35+
return 'bg-muted text-muted-foreground'
36+
}

apps/web/src/routes/transactions.tsx

Lines changed: 77 additions & 120 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,12 @@ import {
2727
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
2828
import type { TouchEvent as ReactTouchEvent } from 'react'
2929
import { apiFetch, ensureAuthenticated } from '../lib/auth'
30+
import {
31+
formatSignedAmount,
32+
formatTransactionType,
33+
getAmountColorClass,
34+
getTransactionBadgeClass,
35+
} from '../lib/format'
3036
import { getSelectedLedgerId } from '../lib/ledger'
3137
import {
3238
consumeOpenCreateTransactionOnce,
@@ -132,11 +138,7 @@ function resolveScheduleDate(year: number, month: number, dayOfMonth: number, on
132138
}
133139

134140
function getTransactionTypeLabel(type: TxType | null) {
135-
if (type === 'INCOME') return '수입'
136-
if (type === 'EXPENSE') return '지출'
137-
if (type === 'DEBT_PAYMENT') return '부채 상환'
138-
if (type === 'SAVING') return '저축'
139-
if (type === 'TRANSFER') return '이체'
141+
if (type) return formatTransactionType(type)
140142
return '템플릿 없음'
141143
}
142144

@@ -620,6 +622,49 @@ function TransactionsPage() {
620622

621623
const isTransferType = (type: TxType) => type === 'TRANSFER' || type === 'DEBT_PAYMENT' || type === 'SAVING'
622624

625+
const getAmountToneClass = (type: TxType) => getAmountColorClass(type, 'semantic')
626+
627+
const getTransactionPreview = (tx: Transaction) => {
628+
const isTransfer = isTransferType(tx.type)
629+
const typeLabel = getTransactionTypeLabel(tx.type)
630+
const memoText = tx.memo?.trim() ?? ''
631+
632+
const categoryLabel = tx.categoryId
633+
? (categoryNameById.get(tx.categoryId) ?? '알 수 없는 카테고리')
634+
: '카테고리 없음'
635+
636+
const accountOutName = tx.accountOutId ? (accountNameById.get(tx.accountOutId) ?? '알 수 없는 계좌') : null
637+
const accountInName = tx.accountInId ? (accountNameById.get(tx.accountInId) ?? '알 수 없는 계좌') : null
638+
639+
const transferAccountLabel = isTransfer
640+
? [accountOutName, accountInName].filter(Boolean).join(' → ') || null
641+
: null
642+
643+
const primaryLabel = isTransfer
644+
? (transferAccountLabel ?? (memoText || '계좌 정보 없음'))
645+
: (memoText || categoryLabel)
646+
647+
const secondaryParts = [typeLabel]
648+
if (isTransfer) {
649+
if (memoText && primaryLabel !== memoText) secondaryParts.push(`메모 ${memoText}`)
650+
} else {
651+
if (primaryLabel !== categoryLabel) secondaryParts.push(categoryLabel)
652+
if (accountOutName) secondaryParts.push(`출금 ${accountOutName}`)
653+
if (accountInName) secondaryParts.push(`입금 ${accountInName}`)
654+
}
655+
if (tx.isFixed) secondaryParts.push('고정')
656+
657+
const secondaryMeta = secondaryParts.join(' · ')
658+
const amountLabel = formatSignedAmount(tx.type, Number(tx.amount))
659+
660+
return {
661+
primaryLabel,
662+
secondaryMeta,
663+
amountLabel,
664+
amountToneClass: getAmountToneClass(tx.type),
665+
}
666+
}
667+
623668
const filteredTransactions = useMemo(() => {
624669
let result = transactions
625670

@@ -1217,46 +1262,7 @@ function TransactionsPage() {
12171262
<div key={date} className="space-y-2">
12181263
<p className="text-xs font-semibold text-muted-foreground">{date}</p>
12191264
{items.map((tx) => {
1220-
const isTransfer = isTransferType(tx.type)
1221-
const memoText = tx.memo?.trim()
1222-
1223-
const transferAccountLabel = isTransfer
1224-
? [
1225-
tx.accountOutId ? accountNameById.get(tx.accountOutId) ?? '알 수 없는 계좌' : null,
1226-
tx.accountInId ? accountNameById.get(tx.accountInId) ?? '알 수 없는 계좌' : null,
1227-
]
1228-
.filter(Boolean)
1229-
.join(' → ') || null
1230-
: null
1231-
1232-
const detailText = [
1233-
transferAccountLabel,
1234-
!transferAccountLabel
1235-
? tx.categoryId
1236-
? (categoryNameById.get(tx.categoryId) ?? '알 수 없는 카테고리')
1237-
: '카테고리 없음'
1238-
: null,
1239-
!isTransfer && tx.accountOutId
1240-
? `출금 ${accountNameById.get(tx.accountOutId) ?? '알 수 없는 계좌'}`
1241-
: null,
1242-
!isTransfer && tx.accountInId
1243-
? `입금 ${accountNameById.get(tx.accountInId) ?? '알 수 없는 계좌'}`
1244-
: null,
1245-
memoText ? `메모 ${memoText}` : null,
1246-
]
1247-
.filter(Boolean)
1248-
.join(' · ')
1249-
1250-
const typeLabel =
1251-
tx.type === 'INCOME'
1252-
? '수입'
1253-
: tx.type === 'EXPENSE'
1254-
? '지출'
1255-
: tx.type === 'DEBT_PAYMENT'
1256-
? '부채 상환'
1257-
: tx.type === 'SAVING'
1258-
? '저축'
1259-
: '이체'
1265+
const preview = getTransactionPreview(tx)
12601266

12611267
return (
12621268
<div
@@ -1279,19 +1285,17 @@ function TransactionsPage() {
12791285
>
12801286
<button
12811287
type="button"
1282-
className="flex flex-1 flex-col items-start gap-1 text-left focus-visible:outline-none"
1288+
className="flex min-w-0 flex-1 flex-col items-start gap-0.5 text-left focus-visible:outline-none"
12831289
onClick={() => { setSwipedTxId(null); setDetailTransaction(tx) }}
12841290
>
1285-
<p className="text-sm font-medium">{typeLabel}</p>
1286-
<p className="text-xs text-muted-foreground">{detailText}</p>
1291+
<p className="w-full truncate text-sm font-medium text-foreground">{preview.primaryLabel}</p>
1292+
<p className="w-full truncate text-xs text-muted-foreground">{preview.secondaryMeta}</p>
12871293
</button>
12881294
<div className="flex items-center gap-1">
12891295
<span
1290-
className={`text-sm font-semibold ${
1291-
isTransfer ? 'text-foreground' : tx.type === 'INCOME' ? 'text-blue-600' : 'text-foreground'
1292-
}`}
1296+
className={`shrink-0 text-sm font-semibold tabular-nums ${preview.amountToneClass}`}
12931297
>
1294-
{isTransfer ? '' : tx.type === 'INCOME' ? '+' : '-'}{Number(tx.amount).toLocaleString()}
1298+
{preview.amountLabel}
12951299
</span>
12961300
{/* 데스크톱 호버 삭제 아이콘 */}
12971301
<button
@@ -1351,35 +1355,7 @@ function TransactionsPage() {
13511355
) : (
13521356
<div className="mb-4 max-h-[45dvh] space-y-2 overflow-y-auto pr-1">
13531357
{selectedDateTransactions.map((tx) => {
1354-
const isTransfer = isTransferType(tx.type)
1355-
const transferAccountLabel = isTransfer
1356-
? [
1357-
tx.accountOutId ? accountNameById.get(tx.accountOutId) ?? '알 수 없는 계좌' : null,
1358-
tx.accountInId ? accountNameById.get(tx.accountInId) ?? '알 수 없는 계좌' : null,
1359-
]
1360-
.filter(Boolean)
1361-
.join(' → ') || null
1362-
: null
1363-
const memoText = tx.memo?.trim()
1364-
const detailText = [
1365-
transferAccountLabel,
1366-
!transferAccountLabel
1367-
? tx.categoryId
1368-
? (categoryNameById.get(tx.categoryId) ?? '알 수 없는 카테고리')
1369-
: '카테고리 없음'
1370-
: null,
1371-
memoText ? `메모 ${memoText}` : null,
1372-
]
1373-
.filter(Boolean)
1374-
.join(' · ')
1375-
const amountLabel = isTransfer
1376-
? `${Number(tx.amount).toLocaleString()}원`
1377-
: `${tx.type === 'INCOME' ? '+' : '-'}${Number(tx.amount).toLocaleString()}원`
1378-
const amountClass = isTransfer
1379-
? 'text-foreground'
1380-
: tx.type === 'INCOME'
1381-
? 'text-blue-600'
1382-
: 'text-foreground'
1358+
const preview = getTransactionPreview(tx)
13831359

13841360
return (
13851361
<button
@@ -1391,21 +1367,13 @@ function TransactionsPage() {
13911367
}}
13921368
className="flex w-full items-center justify-between rounded-md border px-3 py-2 text-left transition-colors hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
13931369
>
1394-
<div className="space-y-1">
1395-
<p className="text-sm font-medium">
1396-
{tx.type === 'INCOME'
1397-
? '수입'
1398-
: tx.type === 'EXPENSE'
1399-
? '지출'
1400-
: tx.type === 'DEBT_PAYMENT'
1401-
? '부채 상환'
1402-
: tx.type === 'SAVING'
1403-
? '저축'
1404-
: '이체'}
1405-
</p>
1406-
<p className="text-xs text-muted-foreground">{detailText}</p>
1370+
<div className="min-w-0 space-y-0.5">
1371+
<p className="truncate text-sm font-medium text-foreground">{preview.primaryLabel}</p>
1372+
<p className="truncate text-xs text-muted-foreground">{preview.secondaryMeta}</p>
14071373
</div>
1408-
<span className={`text-sm font-semibold ${amountClass}`}>{amountLabel}</span>
1374+
<span className={`shrink-0 text-sm font-semibold tabular-nums ${preview.amountToneClass}`}>
1375+
{preview.amountLabel}
1376+
</span>
14091377
</button>
14101378
)
14111379
})}
@@ -1669,19 +1637,8 @@ function TransactionsPage() {
16691637

16701638
{detailTransaction ? (() => {
16711639
const tx = detailTransaction
1672-
const isTransfer = isTransferType(tx.type)
1673-
const typeLabel =
1674-
tx.type === 'INCOME' ? '수입'
1675-
: tx.type === 'EXPENSE' ? '지출'
1676-
: tx.type === 'DEBT_PAYMENT' ? '부채 상환'
1677-
: tx.type === 'SAVING' ? '저축'
1678-
: '이체'
1679-
const typeBadgeClass =
1680-
tx.type === 'INCOME'
1681-
? 'bg-blue-100 text-blue-800'
1682-
: tx.type === 'EXPENSE'
1683-
? 'bg-rose-100 text-rose-800'
1684-
: 'bg-blue-100 text-blue-800'
1640+
const typeLabel = getTransactionTypeLabel(tx.type)
1641+
const typeBadgeClass = getTransactionBadgeClass(tx.type)
16851642

16861643
const hasCategoriesLoaded = categoryNameById.size > 0
16871644
const categoryName = tx.categoryId
@@ -1702,27 +1659,27 @@ function TransactionsPage() {
17021659
>
17031660
<SheetDragHandle />
17041661
<SheetHeader className="mb-4 p-0">
1705-
<SheetTitle className="text-left">거래 상세</SheetTitle>
1706-
</SheetHeader>
1707-
1708-
<div className="space-y-3">
1709-
<div className="flex items-center justify-between">
1662+
<div className="flex items-center justify-between gap-2">
1663+
<SheetTitle className="text-left">거래 상세</SheetTitle>
17101664
<span className={`inline-block rounded-full px-2.5 py-1 text-xs font-medium ${typeBadgeClass}`}>
17111665
{typeLabel}
17121666
</span>
1713-
{tx.isFixed ? (
1667+
</div>
1668+
</SheetHeader>
1669+
1670+
<div className="space-y-3">
1671+
{tx.isFixed ? (
1672+
<div className="flex justify-end">
17141673
<span className="flex items-center gap-1 text-xs text-muted-foreground">
17151674
<Pin className="size-3" /> 고정
17161675
</span>
1717-
) : null}
1718-
</div>
1676+
</div>
1677+
) : null}
17191678

17201679
<div className="flex items-baseline justify-between">
17211680
<span className="text-sm text-muted-foreground">금액</span>
1722-
<span className={`text-lg font-bold ${
1723-
isTransfer ? 'text-foreground' : tx.type === 'INCOME' ? 'text-blue-600' : 'text-foreground'
1724-
}`}>
1725-
{isTransfer ? '' : tx.type === 'INCOME' ? '+' : '-'}{Number(tx.amount).toLocaleString()}
1681+
<span className={`text-lg font-bold ${getAmountToneClass(tx.type)}`}>
1682+
{formatSignedAmount(tx.type, Number(tx.amount))}
17261683
</span>
17271684
</div>
17281685

memories/task.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,7 @@
237237
- 빈 상태 처리
238238
- **의존성:** 1-8, 1-13
239239
- **진행 메모 (2026-03-29):** 이슈 #37 기준 달력 뷰 구현 완료. 월 달력 그리드(모바일 도트/데스크톱 금액), 날짜 탭 슬라이드업(해당 날짜 거래 목록), 주간 합계, 월 수입/지출/순수입, 회계기준일 안내, 빈 날짜 표시까지 반영. 거래 목록의 검색 인라인 확장 기능은 미구현 상태.
240+
- **진행 메모 (2026-04-01):** 이슈 #120 기준 거래 항목 위계를 개선. 목록/달력 날짜 시트의 1행을 `type`에서 `primary summary(메모→카테고리/이체 계좌 경로 fallback)`로 전환하고, 2행에 `type + 보조 메타`를 배치했다. 금액 톤은 `수입=blue`, `지출=destructive`, `이체=muted`로 분리하고 `tabular-nums`를 적용해 가독성을 높였다.
240241

241242
### [1-17] 거래 상세 모달
242243
- **상태:** 완료

0 commit comments

Comments
 (0)