feat: 홈 대시보드 + 영수증 OCR + 카드 할부 + 소수점 매수 등 UI 개선
CI / build (push) Failing after 13m56s

- 홈: 로그인 시 요약(이번달 수입/지출, 순자산, 예산대비지출 바)+바로가기, 비로그인 랜딩
- 영수증 OCR(온디바이스 Tesseract.js): 금액·날짜·상호 추출해 내역 폼 자동입력
- 내역 카드 할부(2~24개월) 입력/표시
- 투자 소수점 수량 입력(step=any)
- 계좌 관리 등록버튼 탭 라인 우측 이동, 정기거래 3단 레이아웃
- 목록 좌측 여백 제거(분류/예산/태그/계좌), 대시보드 일/주 버튼 제거, 도넛/파이 차트 잘림 수정

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
ByungCheol
2026-06-03 16:42:07 +09:00
parent 483cf755ed
commit efbe298a1f
12 changed files with 834 additions and 49 deletions
+123 -3
View File
@@ -2,6 +2,7 @@
import { computed, onMounted, reactive, ref } from 'vue'
import { accountApi } from '@/api/accountApi'
import IconBtn from '@/components/ui/IconBtn.vue'
import { scanReceipt } from '@/utils/receiptOcr'
const now = new Date()
const year = ref(now.getFullYear())
@@ -40,12 +41,53 @@ function resetFilters() {
// 추가/수정 모달
const formOpen = ref(false)
const editId = ref(null)
const form = reactive({ entryDate: '', type: 'EXPENSE', category: '', amount: null, memo: '', walletKind: '', walletId: '', toWalletKind: '', toWalletId: '', principal: null, interest: null })
const form = reactive({ entryDate: '', type: 'EXPENSE', category: '', amount: null, memo: '', walletKind: '', walletId: '', toWalletKind: '', toWalletId: '', principal: null, interest: null, installmentMonths: '' })
const isRepayment = computed(() => form.type === 'REPAYMENT')
// 카드 지출일 때만 할부 입력 노출 (2~24개월, 일시불은 빈값)
const showInstallment = computed(() => form.type === 'EXPENSE' && form.walletKind === 'CARD')
const installmentMonthly = computed(() => {
const m = Number(form.installmentMonths)
const amt = Number(form.amount)
return m >= 2 && amt > 0 ? Math.round(amt / m) : 0
})
const liabilityWallets = computed(() => wallets.value.filter((w) => w.type === 'LOAN' || w.type === 'CARD'))
const submitting = ref(false)
const formError = ref(null)
// 영수증 OCR (온디바이스)
const receiptInput = ref(null)
const ocrRunning = ref(false)
const ocrProgress = ref(0)
const ocrResult = ref(null) // { amount, date, store }
function pickReceipt() {
ocrResult.value = null
receiptInput.value?.click()
}
async function onReceiptFile(e) {
const file = e.target.files?.[0]
e.target.value = '' // 같은 파일 재선택 허용
if (!file) return
ocrRunning.value = true
ocrProgress.value = 0
ocrResult.value = null
formError.value = null
try {
const r = await scanReceipt(file, (p) => (ocrProgress.value = p))
// 추출값이 있을 때만 폼에 채움 (기존 입력 보존하되, 비어있으면 채움)
if (r.amount) form.amount = r.amount
if (r.date) form.entryDate = r.date
if (r.store && !form.memo) form.memo = r.store
ocrResult.value = { amount: r.amount, date: r.date, store: r.store }
if (!r.amount && !r.date) {
formError.value = '영수증에서 정보를 충분히 인식하지 못했습니다. 직접 입력하거나 더 선명한 사진으로 다시 시도하세요.'
}
} catch {
formError.value = '영수증 인식에 실패했습니다. 다시 시도해 주세요.'
} finally {
ocrRunning.value = false
}
}
// 계좌/카드
const wallets = ref([])
async function loadWallets() {
@@ -227,7 +269,7 @@ function todayStr() {
function openCreate() {
editId.value = null
Object.assign(form, { entryDate: todayStr(), type: 'EXPENSE', category: '', amount: null, memo: '', walletKind: '', walletId: '', toWalletKind: '', toWalletId: '', principal: null, interest: null })
Object.assign(form, { entryDate: todayStr(), type: 'EXPENSE', category: '', amount: null, memo: '', walletKind: '', walletId: '', toWalletKind: '', toWalletId: '', principal: null, interest: null, installmentMonths: '' })
selectedTagIds.value = []
cancelAddCategory()
formError.value = null
@@ -245,6 +287,7 @@ function openEdit(e) {
walletId: e.walletId || '',
toWalletKind: walletKindOf(e.toWalletId),
toWalletId: e.toWalletId || '',
installmentMonths: e.installmentMonths || '',
})
// 태그 이름 → id 매핑 (현재 태그 목록 기준)
const nameToId = {}
@@ -319,6 +362,7 @@ async function submit() {
memo: form.memo || null,
walletId: form.walletId || null,
toWalletId: form.type === 'TRANSFER' ? form.toWalletId || null : null,
installmentMonths: showInstallment.value && Number(form.installmentMonths) >= 2 ? Number(form.installmentMonths) : null,
tagIds: selectedTagIds.value,
}
try {
@@ -444,7 +488,8 @@ onMounted(async () => {
<IconBtn icon="trash" title="삭제" variant="danger" size="sm" @click="remove(e)" />
</span>
</div>
<div v-if="e.memo || (e.tags && e.tags.length)" class="ei-line2">
<div v-if="e.memo || e.installmentMonths > 1 || (e.tags && e.tags.length)" class="ei-line2">
<span v-if="e.installmentMonths > 1" class="ei-install">{{ e.installmentMonths }}개월 할부 · {{ won(Math.round(e.amount / e.installmentMonths)) }}</span>
<span v-if="e.memo" class="ei-memo">{{ e.memo }}</span>
<span v-for="t in e.tags" :key="t" class="row-tag">{{ t }}</span>
</div>
@@ -463,6 +508,26 @@ onMounted(async () => {
<h2>{{ editId ? '내역 수정' : '내역 추가' }}</h2>
<form class="entry-form" @submit.prevent="submit">
<!-- 영수증 OCR (온디바이스) -->
<div v-if="!isRepayment" class="receipt-box">
<input
ref="receiptInput" type="file" accept="image/*"
class="receipt-input" @change="onReceiptFile"
/>
<button
type="button" class="receipt-btn"
:disabled="submitting || ocrRunning" @click="pickReceipt"
>📷 영수증 스캔</button>
<span v-if="ocrRunning" class="receipt-status">인식 {{ ocrProgress }}%</span>
<span v-else-if="ocrResult" class="receipt-status ok">
<template v-if="ocrResult.amount">{{ won(ocrResult.amount) }}</template>
<template v-if="ocrResult.date"> · {{ ocrResult.date }}</template>
<template v-if="ocrResult.store"> · {{ ocrResult.store }}</template>
자동 입력됨
</span>
<span v-else class="receipt-hint">사진에서 금액·날짜·상호를 자동 입력</span>
</div>
<label>거래일<input v-model="form.entryDate" type="date" :disabled="submitting" /></label>
<label>구분
<select v-model="form.type" :disabled="submitting">
@@ -487,6 +552,14 @@ onMounted(async () => {
</option>
</select>
</label>
<!-- 카드 지출: 할부 개월수 (일시불 또는 2~24개월) -->
<label v-if="showInstallment">할부
<select v-model="form.installmentMonths" :disabled="submitting">
<option value="">일시불</option>
<option v-for="m in 23" :key="m + 1" :value="m + 1">{{ m + 1 }}개월</option>
</select>
<small v-if="installmentMonthly" class="install-hint"> {{ won(installmentMonthly) }}</small>
</label>
<template v-if="form.type === 'TRANSFER'">
<label>입금 종류
<select v-model="form.toWalletKind" :disabled="submitting" @change="onToWalletKindChange">
@@ -829,6 +902,40 @@ button.primary {
flex-direction: column;
gap: 0.6rem;
}
.receipt-box {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.5rem;
padding: 0.5rem 0.6rem;
border: 1px dashed var(--color-border);
border-radius: 6px;
background: var(--color-background-soft);
}
.receipt-input {
display: none;
}
.receipt-btn {
padding: 0.4rem 0.7rem;
font-size: 0.85rem;
border: 1px solid hsla(160, 100%, 37%, 0.6);
border-radius: 4px;
background: var(--color-background);
color: hsla(160, 100%, 37%, 1);
cursor: pointer;
white-space: nowrap;
}
.receipt-hint {
font-size: 0.76rem;
opacity: 0.6;
}
.receipt-status {
font-size: 0.78rem;
opacity: 0.85;
}
.receipt-status.ok {
color: hsla(160, 100%, 37%, 1);
}
.entry-form label {
display: flex;
flex-direction: column;
@@ -897,6 +1004,19 @@ button.primary {
font-size: 0.75rem;
color: hsla(160, 100%, 37%, 1);
}
.install-hint {
font-size: 0.75rem;
opacity: 0.65;
margin-top: 0.1rem;
}
.ei-install {
font-size: 0.74rem;
padding: 0.05rem 0.4rem;
border: 1px solid hsla(160, 100%, 37%, 0.5);
border-radius: 3px;
color: hsla(160, 100%, 37%, 1);
white-space: nowrap;
}
.row-wallet {
margin-right: 0.35rem;
font-size: 0.75rem;