feat: 카드 알림 자동인식 - 확인 필요 배지/확정 UI/카운트
CI / build (push) Failing after 14m50s

- 미확인(pending) 내역에 '확인필요' 배지 + 행 강조 + '확인' 버튼(즉시 확정)
- 수정 저장 시 미확인 건은 확정 처리
- 가계부 헤더에 '확인 필요 N건' 카운트 배지
- accountApi: pendingCount/confirmEntry

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
ByungCheol
2026-06-03 18:51:43 +09:00
parent 8f8744ba4b
commit 633aa54274
2 changed files with 78 additions and 4 deletions
+8
View File
@@ -153,6 +153,14 @@ export const accountApi = {
return http.delete(`/account/tags/${id}`) return http.delete(`/account/tags/${id}`)
}, },
// 카드 결제 알림 자동인식 (미확인 내역)
pendingCount() {
return http.get('/account/entries/pending/count')
},
confirmEntry(id, payload) {
return http.post(`/account/entries/${id}/confirm`, payload || {})
},
// 영수증 OCR (백엔드가 Google Vision 호출 → 전체 텍스트 반환) // 영수증 OCR (백엔드가 Google Vision 호출 → 전체 텍스트 반환)
ocrReceipt(blob) { ocrReceipt(blob) {
const fd = new FormData() const fd = new FormData()
+70 -4
View File
@@ -14,6 +14,26 @@ const entries = ref([])
const summary = ref({ totalIncome: 0, totalExpense: 0, balance: 0 }) const summary = ref({ totalIncome: 0, totalExpense: 0, balance: 0 })
const loading = ref(false) const loading = ref(false)
const error = ref(null) const error = ref(null)
const pendingCount = ref(0) // 확인 필요(카드 알림 자동인식) 건수
const editingPending = ref(false) // 수정 중인 항목이 미확인 건인지
async function loadPendingCount() {
try {
pendingCount.value = (await accountApi.pendingCount()).count || 0
} catch {
pendingCount.value = 0
}
}
// 미확인 내역 즉시 확정(분류 미지정으로 수락)
async function confirmRow(e) {
try {
await accountApi.confirmEntry(e.id, {})
await load()
await loadPendingCount()
} catch (err) {
alert(err.response?.data?.message || '확인 처리에 실패했습니다.')
}
}
// 검색·필터 // 검색·필터
const filterOpen = ref(false) const filterOpen = ref(false)
@@ -331,6 +351,7 @@ function todayStr() {
function openCreate() { function openCreate() {
editId.value = null editId.value = null
editingPending.value = false
Object.assign(form, { entryDate: todayStr(), type: 'EXPENSE', category: '', amount: null, memo: '', walletKind: '', walletId: '', toWalletKind: '', toWalletId: '', principal: null, interest: null, installmentMonths: '' }) Object.assign(form, { entryDate: todayStr(), type: 'EXPENSE', category: '', amount: null, memo: '', walletKind: '', walletId: '', toWalletKind: '', toWalletId: '', principal: null, interest: null, installmentMonths: '' })
selectedTagIds.value = [] selectedTagIds.value = []
cancelAddCategory() cancelAddCategory()
@@ -339,6 +360,7 @@ function openCreate() {
} }
function openEdit(e) { function openEdit(e) {
editId.value = e.id editId.value = e.id
editingPending.value = !!e.pending
Object.assign(form, { Object.assign(form, {
entryDate: e.entryDate, entryDate: e.entryDate,
type: e.type, type: e.type,
@@ -428,10 +450,18 @@ async function submit() {
tagIds: selectedTagIds.value, tagIds: selectedTagIds.value,
} }
try { try {
if (editId.value) await accountApi.update(editId.value, payload) if (editId.value) {
else await accountApi.create(payload) await accountApi.update(editId.value, payload)
// 미확인(카드 알림) 건이면 수정 저장 후 확정 처리
if (editingPending.value) {
await accountApi.confirmEntry(editId.value, {})
}
} else {
await accountApi.create(payload)
}
formOpen.value = false formOpen.value = false
await load() await load()
await loadPendingCount()
} catch (e) { } catch (e) {
formError.value = e.response?.data?.message || '저장에 실패했습니다.' formError.value = e.response?.data?.message || '저장에 실패했습니다.'
} finally { } finally {
@@ -460,13 +490,14 @@ onMounted(async () => {
loadTagOptions() loadTagOptions()
loadWallets() loadWallets()
loadCategories() loadCategories()
loadPendingCount()
}) })
</script> </script>
<template> <template>
<section class="account"> <section class="account">
<header class="account-head"> <header class="account-head">
<h1>가계부</h1> <h1>가계부<span v-if="pendingCount" class="pending-count">확인 필요 {{ pendingCount }}</span></h1>
<IconBtn icon="plus" title="내역 추가" variant="primary" @click="openCreate" /> <IconBtn icon="plus" title="내역 추가" variant="primary" @click="openCreate" />
</header> </header>
@@ -535,17 +566,19 @@ onMounted(async () => {
</span> </span>
</div> </div>
<ul class="day-items"> <ul class="day-items">
<li v-for="e in g.items" :key="e.id" class="entry-item"> <li v-for="e in g.items" :key="e.id" class="entry-item" :class="{ 'is-pending': e.pending }">
<div class="ei-line1"> <div class="ei-line1">
<span class="ei-cat"> <span class="ei-cat">
<span class="type-dot" :class="dotClass(e.type)"></span> <span class="type-dot" :class="dotClass(e.type)"></span>
<template v-if="e.type === 'TRANSFER'">이체</template> <template v-if="e.type === 'TRANSFER'">이체</template>
<template v-else>{{ e.category || (e.type === 'INCOME' ? '수입' : '지출') }}</template> <template v-else>{{ e.category || (e.type === 'INCOME' ? '수입' : '지출') }}</template>
</span> </span>
<span v-if="e.pending" class="ei-pending">확인필요</span>
<span v-if="e.type === 'TRANSFER'" class="ei-wallet">{{ e.walletName }} {{ e.toWalletName }}</span> <span v-if="e.type === 'TRANSFER'" class="ei-wallet">{{ e.walletName }} {{ e.toWalletName }}</span>
<span v-else-if="e.walletName" class="ei-wallet">{{ e.walletName }}</span> <span v-else-if="e.walletName" class="ei-wallet">{{ e.walletName }}</span>
<span class="ei-amount" :class="amountClass(e.type)">{{ amountSign(e.type) }}{{ won(e.amount) }}</span> <span class="ei-amount" :class="amountClass(e.type)">{{ amountSign(e.type) }}{{ won(e.amount) }}</span>
<span class="ei-act"> <span class="ei-act">
<button v-if="e.pending" type="button" class="confirm-btn" title="확인(확정)" @click="confirmRow(e)">확인</button>
<IconBtn icon="edit" title="수정" size="sm" @click="openEdit(e)" /> <IconBtn icon="edit" title="수정" size="sm" @click="openEdit(e)" />
<IconBtn icon="trash" title="삭제" variant="danger" size="sm" @click="remove(e)" /> <IconBtn icon="trash" title="삭제" variant="danger" size="sm" @click="remove(e)" />
</span> </span>
@@ -876,6 +909,39 @@ button.primary {
padding: 0.5rem 0.1rem; padding: 0.5rem 0.1rem;
border-bottom: 1px solid var(--color-border); border-bottom: 1px solid var(--color-border);
} }
.entry-item.is-pending {
background: rgba(230, 126, 34, 0.07);
}
.ei-pending {
font-size: 0.7rem;
font-weight: 600;
color: #e67e22;
border: 1px solid #e67e22;
border-radius: 3px;
padding: 0.02rem 0.3rem;
white-space: nowrap;
}
.confirm-btn {
padding: 0.18rem 0.5rem;
font-size: 0.78rem;
font-weight: 600;
border: 1px solid hsla(160, 100%, 37%, 1);
border-radius: 4px;
background: hsla(160, 100%, 37%, 1);
color: #fff;
cursor: pointer;
white-space: nowrap;
}
.pending-count {
margin-left: 0.5rem;
font-size: 0.72rem;
font-weight: 600;
vertical-align: middle;
color: #e67e22;
border: 1px solid #e67e22;
border-radius: 999px;
padding: 0.1rem 0.5rem;
}
.ei-line1 { .ei-line1 {
display: flex; display: flex;
align-items: center; align-items: center;