Merge branch 'dev'
This commit is contained in:
@@ -26,6 +26,21 @@ export const accountApi = {
|
||||
remove(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() {
|
||||
|
||||
@@ -445,6 +445,7 @@ function openCreate() {
|
||||
cancelAddCategory()
|
||||
formError.value = null
|
||||
formOpen.value = true
|
||||
tryClipboardOnOpen() // 문자/푸시 클립보드 자동 감지(있으면 배너)
|
||||
}
|
||||
function openEdit(e) {
|
||||
editId.value = e.id
|
||||
@@ -473,6 +474,8 @@ function openEdit(e) {
|
||||
selectedTagIds.value = (e.tags || []).map((n) => nameToId[n]).filter(Boolean)
|
||||
syncCategoryMajor()
|
||||
cancelAddCategory()
|
||||
pasteDetected.value = null
|
||||
pasteOpen.value = false
|
||||
formError.value = null
|
||||
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 () => {
|
||||
// 진입 시 밀린 정기 거래를 먼저 반영한 뒤 목록을 불러온다 (중복 없이)
|
||||
try {
|
||||
@@ -656,6 +783,7 @@ onMounted(async () => {
|
||||
loadWallets()
|
||||
loadCategories()
|
||||
loadPendingCount()
|
||||
loadQuick()
|
||||
checkNotifPermission()
|
||||
})
|
||||
</script>
|
||||
@@ -668,6 +796,19 @@ onMounted(async () => {
|
||||
</header>
|
||||
<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">
|
||||
<span>💳 카드 결제 알림을 자동으로 가계부에 등록하려면 <b>알림 접근 권한</b>이 필요합니다.</span>
|
||||
@@ -806,6 +947,19 @@ onMounted(async () => {
|
||||
<div v-if="ocrRunning" class="receipt-progress"><div class="bar"></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>구분
|
||||
<select v-model="form.type" :disabled="submitting" @change="onTypeChange">
|
||||
@@ -926,6 +1080,14 @@ onMounted(async () => {
|
||||
@click="registerAsRecurring"
|
||||
>🔁 이 내역을 고정지출로 등록</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">
|
||||
<IconBtn icon="close" title="취소" @click="formOpen = false" />
|
||||
<IconBtn icon="save" :title="editId ? '수정' : '등록'" variant="primary" type="submit" :disabled="submitting" />
|
||||
@@ -1578,6 +1740,102 @@ button.primary {
|
||||
border-radius: 3px;
|
||||
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 {
|
||||
display: block;
|
||||
width: 100%;
|
||||
@@ -1591,6 +1849,10 @@ button.primary {
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.to-quick {
|
||||
color: #b8860b;
|
||||
border-color: rgba(184, 134, 11, 0.6);
|
||||
}
|
||||
.to-recurring:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
|
||||
Reference in New Issue
Block a user