@@ -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 : 3 px ;
border - radius : 3 px ;
opacity : 0.85 ;
opacity : 0.85 ;
}
}
/* 자주 쓰는 내역 칩 */
. quick - bar {
display : flex ;
flex - wrap : wrap ;
gap : 0.4 rem ;
margin : 0.4 rem 0 0.2 rem ;
align - items : center ;
}
. quick - chip {
display : inline - flex ;
align - items : center ;
gap : 0.35 rem ;
padding : 0.3 rem 0.6 rem ;
border : 1 px solid var ( -- color - border ) ;
border - radius : 999 px ;
background : var ( -- color - background - soft ) ;
color : var ( -- color - text ) ;
font - size : 0.82 rem ;
cursor : pointer ;
}
. quick - chip . qc - label {
font - weight : 600 ;
}
. quick - chip . qc - amt . income {
color : # 2 e7d32 ;
}
. quick - chip . qc - amt . expense {
color : # c0392b ;
}
. quick - chip . qc - del {
color : # c0392b ;
font - weight : 700 ;
padding : 0 0.1 rem ;
}
. quick - edit {
border : 0 ;
background : transparent ;
color : inherit ;
opacity : 0.6 ;
font - size : 0.78 rem ;
cursor : pointer ;
}
/* 문자/푸시 붙여넣기 */
. paste - box {
display : flex ;
flex - direction : column ;
gap : 0.4 rem ;
}
. paste - btn {
align - self : flex - start ;
padding : 0.4 rem 0.7 rem ;
font - size : 0.82 rem ;
border : 1 px solid var ( -- color - border ) ;
border - radius : 6 px ;
background : var ( -- color - background - soft ) ;
color : var ( -- color - text ) ;
cursor : pointer ;
}
. paste - detected {
font - size : 0.82 rem ;
background : hsla ( 160 , 100 % , 37 % , 0.1 ) ;
border : 1 px solid hsla ( 160 , 100 % , 37 % , 0.4 ) ;
border - radius : 6 px ;
padding : 0.4 rem 0.6 rem ;
display : flex ;
align - items : center ;
gap : 0.5 rem ;
flex - wrap : wrap ;
}
. paste - manual {
display : flex ;
flex - direction : column ;
gap : 0.3 rem ;
}
. paste - manual textarea {
width : 100 % ;
box - sizing : border - box ;
padding : 0.5 rem ;
border : 1 px solid var ( -- color - border ) ;
border - radius : 6 px ;
background : var ( -- color - background - soft ) ;
color : var ( -- color - text ) ;
font : inherit ;
resize : vertical ;
}
. pd - apply {
padding : 0.25 rem 0.7 rem ;
font - size : 0.8 rem ;
font - weight : 600 ;
border : 1 px solid hsla ( 160 , 100 % , 37 % , 1 ) ;
border - radius : 6 px ;
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 : 8 px ;
border - radius : 8 px ;
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 ;