feat(account): 자연어 빠른입력 완성 — 분류·계좌 자동추천 + 내역화면 진입점
Deploy / deploy (push) Failing after 11m25s

- applyParsed의 미선언 categoryMajor 참조 버그 수정(응답 처리 실패 원인)
- 빠른입력 모달 버튼 텍스트화 + 안전영역 하단 고정(가림 해결)
- 클립보드 자동입력을 거래성 텍스트일 때만(로컬 판단, 불필요 AI 호출 제거)
- AI 응답의 분류·결제수단을 폼에 자동 매칭(사용자 실제 목록 기준)
- 내역 화면에 ' 빠른 입력' 텍스트 바 진입점 추가

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
sb
2026-07-05 14:09:28 +09:00
parent 9994456f30
commit 6a4d01b6e1
4 changed files with 130 additions and 20 deletions
+2
View File
@@ -297,6 +297,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = XDT9UV245W;
INFOPLIST_FILE = App/Info.plist; INFOPLIST_FILE = App/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0; IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
@@ -319,6 +320,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = XDT9UV245W;
INFOPLIST_FILE = App/Info.plist; INFOPLIST_FILE = App/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0; IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
+3 -2
View File
@@ -28,8 +28,9 @@ const realApi = {
return http.delete(`/account/entries/${id}`) return http.delete(`/account/entries/${id}`)
}, },
// 문자/푸시 텍스트 파싱(저장 안 함) → 추가 폼 자동채움 // 문자/푸시 텍스트 파싱(저장 안 함) → 추가 폼 자동채움
parseText({ title, text } = {}) { // categoryHints/walletHints: AI가 이 후보들 중에서 분류·결제수단을 고르도록 사용자 목록 전달
return http.post('/account/entries/parse', { title, text }) parseText({ title, text, categoryHints, walletHints } = {}) {
return http.post('/account/entries/parse', { title, text, categoryHints, walletHints })
}, },
// 환율 조회 — 외화 결제 입력 시 통화→원화 환율 자동 채움 // 환율 조회 — 외화 결제 입력 시 통화→원화 환율 자동 채움
fxRate(from, to = 'KRW') { fxRate(from, to = 'KRW') {
+1
View File
@@ -35,6 +35,7 @@ const ICONS = {
send: ['M22 2L11 13', 'M22 2l-7 20-4-9-9-4 20-7z'], send: ['M22 2L11 13', 'M22 2l-7 20-4-9-9-4 20-7z'],
save: ['M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z', 'M17 21v-8H7v8', 'M7 3v5h8'], save: ['M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z', 'M17 21v-8H7v8', 'M7 3v5h8'],
lock: ['M5 11h14a2 2 0 0 1 2 2v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2z', 'M8 11V7a4 4 0 0 1 8 0v4'], lock: ['M5 11h14a2 2 0 0 1 2 2v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2z', 'M8 11V7a4 4 0 0 1 8 0v4'],
sparkle: ['M12 4l1.6 6.4L20 12l-6.4 1.6L12 20l-1.6-6.4L4 12l6.4-1.6z'],
} }
const paths = computed(() => ICONS[props.icon] || ICONS.plus) const paths = computed(() => ICONS[props.icon] || ICONS.plus)
+124 -18
View File
@@ -477,6 +477,12 @@ function openCreate() {
formOpen.value = true formOpen.value = true
tryClipboardOnOpen() // 문자/푸시 클립보드 자동 감지(있으면 배너) tryClipboardOnOpen() // 문자/푸시 클립보드 자동 감지(있으면 배너)
} }
// 내역 화면에서 바로 빠른입력: 추가 폼을 준비해 열고, 그 위에 빠른입력 모달을 띄운다.
// 분석·채우기 후 빠른입력 모달은 닫히고, 채워진 추가 폼이 남아 확인·저장.
function openQuickInput() {
openCreate()
openPasteModal()
}
function openEdit(e) { function openEdit(e) {
editId.value = e.id editId.value = e.id
editingPending.value = !!e.pending editingPending.value = !!e.pending
@@ -774,51 +780,67 @@ function applyParsed(p) {
if (p.amount) form.amount = Number(p.amount) if (p.amount) form.amount = Number(p.amount)
if (p.merchant) form.memo = p.merchant if (p.merchant) form.memo = p.merchant
if (p.date) form.entryDate = p.date 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 pasteDetected.value = null
pasteModalOpen.value = false pasteModalOpen.value = false
pasteText.value = '' pasteText.value = ''
pasteError.value = '' pasteError.value = ''
formOpen.value = true // 내역 화면에서 바로 빠른입력한 경우에도 채워진 추가 폼이 보이도록 보장
} }
async function parseAndApply(text, { silent } = {}) { async function parseAndApply(text, { silent } = {}) {
const t = (text || '').trim() const t = (text || '').trim()
if (!t) return false if (!t) return false
try { 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 (p && p.recognized) {
if (silent) pasteDetected.value = p if (silent) pasteDetected.value = p
else applyParsed(p) else applyParsed(p)
return true return true
} }
} catch { } catch (e) {
// 무시 // 네트워크 오류는 조용히 폴백하되, 예외는 콘솔에 남겨 원인 추적이 가능하게 한다
console.error('[parseAndApply] 실패:', e)
} }
return false return false
} }
// 추가 팝업 열 때 클립보드 자동 감지(권한 막히면 조용히 무시) → 버튼 배지로 안내 // 클립보드가 카드문자/거래처럼 보이는지 로컬로만 판단(AI 호출 없음 — 비용·오작동 방지)
function looksLikeTxn(t) {
return !!t && /\d/.test(t) && /(원|₩|승인|입금|출금|결제|카드|이체)/.test(t)
}
// 추가 팝업 열 때: 클립보드에 거래성 텍스트가 있으면 버튼에 점 배지만 표시(자동 전송 안 함)
async function tryClipboardOnOpen() { async function tryClipboardOnOpen() {
pasteDetected.value = null pasteDetected.value = null
pasteText.value = ''
try { try {
const txt = await navigator.clipboard.readText() const txt = await navigator.clipboard.readText()
await parseAndApply(txt, { silent: true }) if (looksLikeTxn(txt)) pasteDetected.value = { local: true }
} catch { } catch {
// 클립보드 접근 불가 — 버튼으로 수동 // 클립보드 접근 불가 — 무시
} }
} }
// '문자·푸시' 버튼 → 모달 열기 + 클립보드 자동 읽기 // '빠른 입력' 버튼 → 모달 열기. 클립보드가 카드문자처럼 보일 때만 미리 채워줌(자동 AI 전송 없음 — 사용자가 버튼으로 분석)
async function openPasteModal() { async function openPasteModal() {
pasteError.value = '' pasteError.value = ''
pasteModalOpen.value = true pasteModalOpen.value = true
if (!pasteText.value) { if (!pasteText.value) {
try { try {
const txt = await navigator.clipboard.readText() const txt = await navigator.clipboard.readText()
if (txt) { if (looksLikeTxn(txt)) pasteText.value = txt
pasteText.value = txt
await parseAndApply(txt, { silent: true })
}
} catch { } catch {
// 클립보드 접근 불가 — 수동 붙여넣기 // 클립보드 접근 불가 — 수동 입력
} }
} }
} }
@@ -932,6 +954,16 @@ onMounted(async () => {
</div> </div>
</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="error" class="msg error">{{ error }}</p>
<p v-if="loading" class="msg">불러오는 중...</p> <p v-if="loading" class="msg">불러오는 중...</p>
@@ -1252,13 +1284,13 @@ onMounted(async () => {
<h2>빠른 입력</h2> <h2>빠른 입력</h2>
<p class="pm-hint">자연어로 입력하거나 문자·푸시를 붙여넣고 분석하면 구분·금액·메모·날짜가 자동 채워집니다.</p> <p class="pm-hint">자연어로 입력하거나 문자·푸시를 붙여넣고 분석하면 구분·금액·메모·날짜가 자동 채워집니다.</p>
<textarea v-model="pasteText" rows="5" class="pm-text" placeholder="예: 어제 스타벅스 5천원 / 오늘 점심 9000원&#10;또는 문자 붙여넣기: [Web발신] 신한카드 승인 12,000원 스타벅스 06/27"></textarea> <textarea v-model="pasteText" rows="5" class="pm-text" placeholder="예: 어제 스타벅스 5천원 / 오늘 점심 9000원&#10;또는 문자 붙여넣기: [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> 감지: {{ pasteDetected.type === 'INCOME' ? '수입' : '지출' }} {{ won(pasteDetected.amount) }}<template v-if="pasteDetected.merchant"> · {{ pasteDetected.merchant }}</template>
</div> </div>
<p v-if="pasteError" class="msg error">{{ pasteError }}</p> <p v-if="pasteError" class="msg error">{{ pasteError }}</p>
<div class="buttons"> <div class="buttons pm-buttons">
<IconBtn icon="close" title="취소" @click="pasteModalOpen = false" /> <button type="button" class="pm-btn pm-cancel" @click="pasteModalOpen = false">취소</button>
<IconBtn icon="save" title="분석·채우기" variant="primary" @click="analyzePasted" /> <button type="button" class="pm-btn pm-primary" @click="analyzePasted"> 분석해서 채우기</button>
</div> </div>
</div> </div>
</div> </div>
@@ -1365,6 +1397,54 @@ button.primary {
gap: 0.75rem; gap: 0.75rem;
margin-bottom: 1.25rem; margin-bottom: 1.25rem;
} }
/* 자연어 빠른 입력 바 */
.quick-input-bar {
display: flex;
align-items: center;
gap: 0.7rem;
width: 100%;
margin-bottom: 1rem;
padding: 0.7rem 0.9rem;
border: 1px solid hsla(160, 100%, 37%, 0.45);
border-radius: 12px;
background: hsla(160, 100%, 37%, 0.08);
color: var(--color-text);
cursor: pointer;
text-align: left;
transition: background 0.15s ease;
}
.quick-input-bar:hover {
background: hsla(160, 100%, 37%, 0.14);
}
.quick-input-bar .qib-icon {
font-size: 1.25rem;
line-height: 1;
}
.quick-input-bar .qib-body {
display: flex;
flex-direction: column;
gap: 0.1rem;
flex: 1;
min-width: 0;
}
.quick-input-bar .qib-title {
font-weight: 700;
font-size: 0.95rem;
color: hsla(160, 100%, 32%, 1);
}
.quick-input-bar .qib-sub {
font-size: 0.78rem;
opacity: 0.65;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.quick-input-bar .qib-plus {
font-size: 1.3rem;
font-weight: 700;
color: hsla(160, 100%, 37%, 1);
flex: 0 0 auto;
}
.card { .card {
flex: 1; flex: 1;
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
@@ -2120,6 +2200,32 @@ button.primary {
font: inherit; font: inherit;
resize: vertical; resize: vertical;
} }
/* 빠른입력 하단 버튼: 텍스트 표기 + 홈 인디케이터 위로(안전영역 밀림 방지) */
.paste-modal .pm-buttons {
bottom: 0;
gap: 0.6rem;
padding-bottom: calc(0.2rem + env(safe-area-inset-bottom));
}
.pm-btn {
padding: 0.85rem 1rem;
border-radius: 10px;
font-size: 0.95rem;
font-weight: 700;
cursor: pointer;
border: 1px solid var(--color-border);
}
.pm-cancel {
flex: 0 0 auto;
min-width: 84px;
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 { .to-recurring {
display: block; display: block;
width: 100%; width: 100%;