feat: 자주 쓰는 내역 칩(바로 등록) + 문자/푸시 붙여넣기 자동채움
CI / build (push) Failing after 15m32s

- 가계부 상단 자주 쓰는 내역 칩: 탭하면 오늘 날짜로 즉시 등록(+안내), 편집 모드로 삭제
- 내역 추가/수정 폼: ' 자주 쓰는 내역으로 저장' 버튼(수입/지출)
- 추가 팝업 열 때 클립보드 자동감지 배너 + '📋 문자·푸시 붙여넣기' 버튼
  (실패 시 수동 붙여넣기 입력칸) → 구분/금액/메모/날짜 자동 채움
- accountApi: parseText / quickEntries CRUD

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
ByungCheol
2026-06-27 14:50:10 +09:00
parent dd17fae5f4
commit 8e651c786f
2 changed files with 277 additions and 0 deletions
+15
View File
@@ -26,6 +26,21 @@ export const accountApi = {
remove(id) { remove(id) {
return http.delete(`/account/entries/${id}`) return http.delete(`/account/entries/${id}`)
}, },
// 문자/푸시 텍스트 파싱(저장 안 함) → 추가 폼 자동채움
parseText({ title, text } = {}) {
return http.post('/account/entries/parse', { title, text })
},
// 자주 쓰는 내역(빠른 등록 템플릿)
quickEntries() {
return http.get('/account/quick-entries')
},
createQuickEntry(payload) {
return http.post('/account/quick-entries', payload)
},
removeQuickEntry(id) {
return http.delete(`/account/quick-entries/${id}`)
},
// 계좌/카드 (사용자별) // 계좌/카드 (사용자별)
wallets() { wallets() {
+262
View File
@@ -445,6 +445,7 @@ function openCreate() {
cancelAddCategory() cancelAddCategory()
formError.value = null formError.value = null
formOpen.value = true formOpen.value = true
tryClipboardOnOpen() // 문자/푸시 클립보드 자동 감지(있으면 배너)
} }
function openEdit(e) { function openEdit(e) {
editId.value = e.id editId.value = e.id
@@ -473,6 +474,8 @@ function openEdit(e) {
selectedTagIds.value = (e.tags || []).map((n) => nameToId[n]).filter(Boolean) selectedTagIds.value = (e.tags || []).map((n) => nameToId[n]).filter(Boolean)
syncCategoryMajor() syncCategoryMajor()
cancelAddCategory() cancelAddCategory()
pasteDetected.value = null
pasteOpen.value = false
formError.value = null formError.value = null
formOpen.value = true formOpen.value = true
} }
@@ -644,6 +647,130 @@ async function doRegisterRecurring() {
} }
} }
// ===== 자주 쓰는 내역(빠른 등록 템플릿) =====
const quickEntries = ref([])
const quickEditMode = ref(false)
async function loadQuick() {
try {
quickEntries.value = await accountApi.quickEntries()
} catch {
quickEntries.value = []
}
}
function quickLabel(q) {
return q.label || q.category || q.memo || (q.type === 'INCOME' ? '수입' : '지출')
}
// 칩 탭 → 오늘 날짜로 즉시 등록
async function useQuick(q) {
if (quickEditMode.value) return
try {
await accountApi.create({
entryDate: todayStr(),
type: q.type,
category: q.category || null,
amount: Number(q.amount),
memo: q.memo || null,
walletId: q.walletId || null,
toWalletId: null,
installmentMonths: null,
tagIds: [],
})
await load()
await loadPendingCount()
flash(`'${quickLabel(q)}' ${won(q.amount)} 등록했습니다.`)
} catch (e) {
flash(e.response?.data?.message || '빠른 등록에 실패했습니다.')
}
}
async function removeQuick(q) {
try {
await accountApi.removeQuickEntry(q.id)
await loadQuick()
} catch (e) {
flash(e.response?.data?.message || '삭제에 실패했습니다.')
}
}
// 현재 폼 값을 자주 쓰는 내역으로 저장
async function saveAsQuick() {
if (form.type !== 'INCOME' && form.type !== 'EXPENSE') return
const amount = Number(form.amount)
if (!amount || amount <= 0) {
formError.value = '금액을 올바르게 입력하세요.'
return
}
try {
await accountApi.createQuickEntry({
label: (form.memo || form.category || '').trim() || null,
type: form.type,
category: form.category || null,
amount,
memo: form.memo || null,
walletId: form.walletId || null,
})
await loadQuick()
flash('자주 쓰는 내역으로 저장했습니다.')
} catch (e) {
formError.value = e.response?.data?.message || '저장에 실패했습니다.'
}
}
// ===== 문자/푸시 붙여넣기 → 파싱 자동채움 =====
const pasteDetected = ref(null) // 클립보드 자동감지 결과(배너)
const pasteOpen = ref(false) // 수동 붙여넣기 입력칸
const pasteText = ref('')
function applyParsed(p) {
if (!p || !p.recognized) return
form.type = p.type === 'INCOME' ? 'INCOME' : 'EXPENSE'
if (p.amount) form.amount = Number(p.amount)
if (p.merchant) form.memo = p.merchant
if (p.date) form.entryDate = p.date
categoryMajor.value = ''
pasteDetected.value = null
pasteOpen.value = false
pasteText.value = ''
}
async function parseAndApply(text, { silent } = {}) {
const t = (text || '').trim()
if (!t) return false
try {
const p = await accountApi.parseText({ text: t })
if (p && p.recognized) {
if (silent) pasteDetected.value = p
else applyParsed(p)
return true
}
} catch {
// 무시
}
return false
}
// 추가 팝업 열 때 클립보드 자동 감지(권한 막히면 조용히 무시)
async function tryClipboardOnOpen() {
pasteDetected.value = null
pasteOpen.value = false
pasteText.value = ''
try {
const txt = await navigator.clipboard.readText()
await parseAndApply(txt, { silent: true })
} catch {
// 클립보드 접근 불가 — 버튼으로 수동
}
}
// '붙여넣기' 버튼: 클립보드 읽어 바로 적용, 실패 시 수동 입력칸
async function pasteFromClipboard() {
try {
const txt = await navigator.clipboard.readText()
const ok = await parseAndApply(txt, { silent: false })
if (!ok) pasteOpen.value = true
} catch {
pasteOpen.value = true
}
}
async function analyzePasted() {
const ok = await parseAndApply(pasteText.value, { silent: false })
if (!ok) formError.value = '거래 내용을 인식하지 못했습니다.'
}
onMounted(async () => { onMounted(async () => {
// 진입 시 밀린 정기 거래를 먼저 반영한 뒤 목록을 불러온다 (중복 없이) // 진입 시 밀린 정기 거래를 먼저 반영한 뒤 목록을 불러온다 (중복 없이)
try { try {
@@ -656,6 +783,7 @@ onMounted(async () => {
loadWallets() loadWallets()
loadCategories() loadCategories()
loadPendingCount() loadPendingCount()
loadQuick()
checkNotifPermission() checkNotifPermission()
}) })
</script> </script>
@@ -668,6 +796,19 @@ onMounted(async () => {
</header> </header>
<Transition name="fade"><p v-if="flashMsg" class="flash">{{ flashMsg }}</p></Transition> <Transition name="fade"><p v-if="flashMsg" class="flash">{{ flashMsg }}</p></Transition>
<!-- 자주 쓰는 내역(빠른 등록) 오늘 날짜로 즉시 등록 -->
<div v-if="quickEntries.length" class="quick-bar">
<button
v-for="q in quickEntries" :key="q.id" type="button" class="quick-chip"
:title="quickEditMode ? '' : '오늘 날짜로 바로 등록'" @click="useQuick(q)"
>
<span class="qc-label">{{ quickLabel(q) }}</span>
<span class="qc-amt" :class="q.type === 'INCOME' ? 'income' : 'expense'">{{ q.type === 'INCOME' ? '+' : '-' }}{{ won(q.amount) }}</span>
<span v-if="quickEditMode" class="qc-del" title="삭제" @click.stop="removeQuick(q)">×</span>
</button>
<button type="button" class="quick-edit" @click="quickEditMode = !quickEditMode">{{ quickEditMode ? '완료' : '편집' }}</button>
</div>
<!-- 카드 결제 알림 자동인식 권한 안내 (네이티브, 미허용 ) --> <!-- 카드 결제 알림 자동인식 권한 안내 (네이티브, 미허용 ) -->
<div v-if="notifNative && !notifEnabled" class="notif-banner"> <div v-if="notifNative && !notifEnabled" class="notif-banner">
<span>💳 카드 결제 알림을 자동으로 가계부에 등록하려면 <b>알림 접근 권한</b> 필요합니다.</span> <span>💳 카드 결제 알림을 자동으로 가계부에 등록하려면 <b>알림 접근 권한</b> 필요합니다.</span>
@@ -806,6 +947,19 @@ onMounted(async () => {
<div v-if="ocrRunning" class="receipt-progress"><div class="bar"></div></div> <div v-if="ocrRunning" class="receipt-progress"><div class="bar"></div></div>
</div> </div>
<!-- 문자/푸시 붙여넣기 자동 채움 (iPhone 알림 자동수집 불가 대비) -->
<div v-if="!isRepayment" class="paste-box">
<button type="button" class="paste-btn" :disabled="submitting" @click="pasteFromClipboard">📋 문자·푸시 붙여넣기</button>
<div v-if="pasteDetected" class="paste-detected">
감지: {{ pasteDetected.type === 'INCOME' ? '수입' : '지출' }} {{ won(pasteDetected.amount) }}<template v-if="pasteDetected.merchant"> · {{ pasteDetected.merchant }}</template>
<button type="button" class="pd-apply" @click="applyParsed(pasteDetected)">채우기</button>
</div>
<div v-if="pasteOpen" class="paste-manual">
<textarea v-model="pasteText" rows="3" placeholder="문자/푸시 내용을 붙여넣고 분석"></textarea>
<button type="button" class="pd-apply" @click="analyzePasted">분석</button>
</div>
</div>
<label>거래일<input v-model="form.entryDate" type="date" :disabled="submitting" /></label> <label>거래일<input v-model="form.entryDate" type="date" :disabled="submitting" /></label>
<label>구분 <label>구분
<select v-model="form.type" :disabled="submitting" @change="onTypeChange"> <select v-model="form.type" :disabled="submitting" @change="onTypeChange">
@@ -926,6 +1080,14 @@ onMounted(async () => {
@click="registerAsRecurring" @click="registerAsRecurring"
>🔁 내역을 고정지출로 등록</button> >🔁 내역을 고정지출로 등록</button>
<button
v-if="form.type === 'INCOME' || form.type === 'EXPENSE'"
type="button"
class="to-recurring to-quick"
:disabled="submitting"
@click="saveAsQuick"
> 자주 쓰는 내역으로 저장</button>
<div class="buttons"> <div class="buttons">
<IconBtn icon="close" title="취소" @click="formOpen = false" /> <IconBtn icon="close" title="취소" @click="formOpen = false" />
<IconBtn icon="save" :title="editId ? '수정' : '등록'" variant="primary" type="submit" :disabled="submitting" /> <IconBtn icon="save" :title="editId ? '수정' : '등록'" variant="primary" type="submit" :disabled="submitting" />
@@ -1578,6 +1740,102 @@ button.primary {
border-radius: 3px; border-radius: 3px;
opacity: 0.85; opacity: 0.85;
} }
/* 자주 쓰는 내역 칩 */
.quick-bar {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
margin: 0.4rem 0 0.2rem;
align-items: center;
}
.quick-chip {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.3rem 0.6rem;
border: 1px solid var(--color-border);
border-radius: 999px;
background: var(--color-background-soft);
color: var(--color-text);
font-size: 0.82rem;
cursor: pointer;
}
.quick-chip .qc-label {
font-weight: 600;
}
.quick-chip .qc-amt.income {
color: #2e7d32;
}
.quick-chip .qc-amt.expense {
color: #c0392b;
}
.quick-chip .qc-del {
color: #c0392b;
font-weight: 700;
padding: 0 0.1rem;
}
.quick-edit {
border: 0;
background: transparent;
color: inherit;
opacity: 0.6;
font-size: 0.78rem;
cursor: pointer;
}
/* 문자/푸시 붙여넣기 */
.paste-box {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.paste-btn {
align-self: flex-start;
padding: 0.4rem 0.7rem;
font-size: 0.82rem;
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-background-soft);
color: var(--color-text);
cursor: pointer;
}
.paste-detected {
font-size: 0.82rem;
background: hsla(160, 100%, 37%, 0.1);
border: 1px solid hsla(160, 100%, 37%, 0.4);
border-radius: 6px;
padding: 0.4rem 0.6rem;
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.paste-manual {
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.paste-manual textarea {
width: 100%;
box-sizing: border-box;
padding: 0.5rem;
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-background-soft);
color: var(--color-text);
font: inherit;
resize: vertical;
}
.pd-apply {
padding: 0.25rem 0.7rem;
font-size: 0.8rem;
font-weight: 600;
border: 1px solid hsla(160, 100%, 37%, 1);
border-radius: 6px;
background: hsla(160, 100%, 37%, 1);
color: #fff;
cursor: pointer;
align-self: flex-start;
}
.to-recurring { .to-recurring {
display: block; display: block;
width: 100%; width: 100%;
@@ -1591,6 +1849,10 @@ button.primary {
border-radius: 8px; border-radius: 8px;
cursor: pointer; cursor: pointer;
} }
.to-quick {
color: #b8860b;
border-color: rgba(184, 134, 11, 0.6);
}
.to-recurring:disabled { .to-recurring:disabled {
opacity: 0.5; opacity: 0.5;
cursor: default; cursor: default;