@@ -477,6 +477,12 @@ function openCreate() {
formOpen . value = true
tryClipboardOnOpen ( ) // 문자/푸시 클립보드 자동 감지(있으면 배너)
}
// 내역 화면에서 바로 빠른입력: 추가 폼을 준비해 열고, 그 위에 빠른입력 모달을 띄운다.
// 분석·채우기 후 빠른입력 모달은 닫히고, 채워진 추가 폼이 남아 확인·저장.
function openQuickInput ( ) {
openCreate ( )
openPasteModal ( )
}
function openEdit ( e ) {
editId . value = e . id
editingPending . value = ! ! e . pending
@@ -774,51 +780,67 @@ function applyParsed(p) {
if ( p . amount ) form . amount = Number ( p . amount )
if ( p . merchant ) form . memo = p . merchant
if ( p . date ) form . entryDate = p . date
categoryMajor . value = ''
// AI 추천 분류(후보 목록 중에서 선택된 값). 없으면 사용자가 직접 선택
form . category = p . category || ''
// AI 추천 결제수단(계좌) — 이름으로 실제 계좌를 찾아 종류·계좌 자동 선택
if ( p . wallet ) {
const w = wallets . value . find ( ( x ) => x . name === p . wallet )
if ( w ) {
form . walletKind = w . type
form . walletId = w . id
}
}
pasteDetected . value = null
pasteModalOpen . value = false
pasteText . value = ''
pasteError . value = ''
formOpen . value = true // 내역 화면에서 바로 빠른입력한 경우에도 채워진 추가 폼이 보이도록 보장
}
async function parseAndApply ( text , { silent } = { } ) {
const t = ( text || '' ) . trim ( )
if ( ! t ) return false
try {
const p = await accountApi . parseText ( { text : t } )
// 사용자의 실제 분류·계좌 이름을 함께 보내 AI가 그 중에서 고르게 함
const p = await accountApi . parseText ( {
text : t ,
categoryHints : allCategoryNames . value ,
walletHints : wallets . value . map ( ( w ) => w . name ) ,
} )
if ( p && p . recognized ) {
if ( silent ) pasteDetected . value = p
else applyParsed ( p )
return true
}
} catch {
// 무시
} catch ( e ) {
// 네트워크 오류는 조용히 폴백하되, 예외는 콘솔에 남겨 원인 추적이 가능하게 한다
console . error ( '[parseAndApply] 실패:' , e )
}
return false
}
// 추가 팝업 열 때 클립보드 자동 감지(권한 막히면 조용히 무시) → 버튼 배지로 안내
// 클립보드가 카드문자/거래처럼 보이는지 로컬로만 판단(AI 호출 없음 — 비용·오작동 방지)
function looksLikeTxn ( t ) {
return ! ! t && /\d/ . test ( t ) && /(원|₩|승인|입금|출금|결제|카드|이체)/ . test ( t )
}
// 추가 팝업 열 때: 클립보드에 거래성 텍스트가 있으면 버튼에 점 배지만 표시(자동 전송 안 함)
async function tryClipboardOnOpen ( ) {
pasteDetected . value = null
pasteText . value = ''
try {
const txt = await navigator . clipboard . readText ( )
await parseAndApply ( txt , { silent : true } )
if ( looksLikeTxn ( txt ) ) pasteDetected . value = { local : true }
} catch {
// 클립보드 접근 불가 — 버튼으로 수동
// 클립보드 접근 불가 — 무시
}
}
// '문자·푸시 ' 버튼 → 모달 열기 + 클립보드 자동 읽기
// '빠른 입력 ' 버튼 → 모달 열기. 클립보드가 카드문자처럼 보일 때만 미리 채워줌(자동 AI 전송 없음 — 사용자가 버튼으로 분석)
async function openPasteModal ( ) {
pasteError . value = ''
pasteModalOpen . value = true
if ( ! pasteText . value ) {
try {
const txt = await navigator . clipboard . readText ( )
if ( txt ) {
pasteText . value = txt
await parseAndApply ( txt , { silent : true } )
}
if ( looksLikeTxn ( txt ) ) pasteText . value = txt
} catch {
// 클립보드 접근 불가 — 수동 붙여넣기
// 클립보드 접근 불가 — 수동 입력
}
}
}
@@ -932,6 +954,16 @@ onMounted(async () => {
< / div >
< / div >
<!-- 자연어 빠른 입력 진입점 ( AI ) : 한 줄 입력 → 폼 자동 채움 -- >
< button type = "button" class = "quick-input-bar" @click ="openQuickInput" >
< span class = "qib-icon" aria -hidden = " true " > ✨ < / span >
< span class = "qib-body" >
< span class = "qib-title" > 빠른 입력 < / span >
< span class = "qib-sub" > “ 어제 삼성카드로 라면 5 천원 ” 처럼 한 줄로 < / span >
< / span >
< span class = "qib-plus" aria -hidden = " true " > + < / span >
< / button >
< p v-if = "error" class="msg error" > {{ error }} < / p >
< p v-if = "loading" class="msg" > 불러오는 중... < / p >
@@ -1252,13 +1284,13 @@ onMounted(async () => {
< h2 > 빠른 입력 < / h2 >
< p class = "pm-hint" > 자연어로 입력하거나 문자 · 푸시를 붙여넣고 분석하면 구분 · 금액 · 메모 · 날짜가 자동 채워집니다 . < / p >
< textarea v-model = "pasteText" rows="5" class="pm-text" placeholder="예: 어제 스타벅스 5천원 / 오늘 점심 9000원 또는 문자 붙여넣기: [Web발신] 신한카드 승인 12,000원 스타벅스 06/27" > < / textarea >
< div v-if = "pasteDetected" class="paste-detected" >
< div v-if = "pasteDetected && pasteDetected.amount " class="paste-detected" >
감지 : {{ pasteDetected.type = = = ' INCOME ' ? ' 수입 ' : ' 지출 ' }} {{ won ( pasteDetected.amount ) }} < template v-if = "pasteDetected.merchant" > · {{ pasteDetected.merchant }} < / template >
< / div >
< p v-if = "pasteError" class="msg error" > {{ pasteError }} < / p >
< div class = "buttons" >
< IconBtn icon = "close " title = "취소 " @click ="pasteModalOpen = false" / >
< IconBtn icon = "save " title = "분석·채우기" variant = " primary" @click ="analyzePasted" / >
< div class = "buttons pm-buttons " >
< button type = "button " class = "pm-btn pm-cancel " @click ="pasteModalOpen = false" > 취소 < / button >
< button type = "button " class = "pm-btn pm- primary" @click ="analyzePasted" > ✨ 분석해서 채우기 < / button >
< / div >
< / div >
< / div >
@@ -1365,6 +1397,54 @@ button.primary {
gap : 0.75 rem ;
margin - bottom : 1.25 rem ;
}
/* 자연어 빠른 입력 바 */
. quick - input - bar {
display : flex ;
align - items : center ;
gap : 0.7 rem ;
width : 100 % ;
margin - bottom : 1 rem ;
padding : 0.7 rem 0.9 rem ;
border : 1 px solid hsla ( 160 , 100 % , 37 % , 0.45 ) ;
border - radius : 12 px ;
background : hsla ( 160 , 100 % , 37 % , 0.08 ) ;
color : var ( -- color - text ) ;
cursor : pointer ;
text - align : left ;
transition : background 0.15 s ease ;
}
. quick - input - bar : hover {
background : hsla ( 160 , 100 % , 37 % , 0.14 ) ;
}
. quick - input - bar . qib - icon {
font - size : 1.25 rem ;
line - height : 1 ;
}
. quick - input - bar . qib - body {
display : flex ;
flex - direction : column ;
gap : 0.1 rem ;
flex : 1 ;
min - width : 0 ;
}
. quick - input - bar . qib - title {
font - weight : 700 ;
font - size : 0.95 rem ;
color : hsla ( 160 , 100 % , 32 % , 1 ) ;
}
. quick - input - bar . qib - sub {
font - size : 0.78 rem ;
opacity : 0.65 ;
white - space : nowrap ;
overflow : hidden ;
text - overflow : ellipsis ;
}
. quick - input - bar . qib - plus {
font - size : 1.3 rem ;
font - weight : 700 ;
color : hsla ( 160 , 100 % , 37 % , 1 ) ;
flex : 0 0 auto ;
}
. card {
flex : 1 ;
border : 1 px solid var ( -- color - border ) ;
@@ -2120,6 +2200,32 @@ button.primary {
font : inherit ;
resize : vertical ;
}
/* 빠른입력 하단 버튼: 텍스트 표기 + 홈 인디케이터 위로(안전영역 밀림 방지) */
. paste - modal . pm - buttons {
bottom : 0 ;
gap : 0.6 rem ;
padding - bottom : calc ( 0.2 rem + env ( safe - area - inset - bottom ) ) ;
}
. pm - btn {
padding : 0.85 rem 1 rem ;
border - radius : 10 px ;
font - size : 0.95 rem ;
font - weight : 700 ;
cursor : pointer ;
border : 1 px solid var ( -- color - border ) ;
}
. pm - cancel {
flex : 0 0 auto ;
min - width : 84 px ;
background : var ( -- color - background - soft ) ;
color : var ( -- color - text ) ;
}
. pm - primary {
flex : 1 ;
background : hsla ( 160 , 100 % , 37 % , 1 ) ;
border - color : hsla ( 160 , 100 % , 37 % , 1 ) ;
color : # fff ;
}
. to - recurring {
display : block ;
width : 100 % ;