- 영수증 스캔·문자·푸시 버튼을 상단 한 행(rb-actions)에 나란히, 안내문구는 아래 - 문자·푸시 붙여넣기를 하단 인라인 → 모달로(텍스트영역+감지+분석·채우기) - 팝업 열 때 클립보드 자동감지 시 버튼에 ● 배지 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -477,7 +477,8 @@ function openEdit(e) {
|
|||||||
syncCategoryMajor()
|
syncCategoryMajor()
|
||||||
cancelAddCategory()
|
cancelAddCategory()
|
||||||
pasteDetected.value = null
|
pasteDetected.value = null
|
||||||
pasteOpen.value = false
|
pasteModalOpen.value = false
|
||||||
|
pasteText.value = ''
|
||||||
formError.value = null
|
formError.value = null
|
||||||
formInfo.value = ''
|
formInfo.value = ''
|
||||||
formOpen.value = true
|
formOpen.value = true
|
||||||
@@ -737,10 +738,11 @@ async function saveAsQuick() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 문자/푸시 붙여넣기 → 파싱 자동채움 =====
|
// ===== 문자/푸시 붙여넣기 → 파싱 자동채움 (모달) =====
|
||||||
const pasteDetected = ref(null) // 클립보드 자동감지 결과(배너)
|
const pasteDetected = ref(null) // 자동감지 결과(버튼 배지 + 모달 배너)
|
||||||
const pasteOpen = ref(false) // 수동 붙여넣기 입력칸
|
const pasteModalOpen = ref(false) // 붙여넣기 모달
|
||||||
const pasteText = ref('')
|
const pasteText = ref('')
|
||||||
|
const pasteError = ref('')
|
||||||
function applyParsed(p) {
|
function applyParsed(p) {
|
||||||
if (!p || !p.recognized) return
|
if (!p || !p.recognized) return
|
||||||
form.type = p.type === 'INCOME' ? 'INCOME' : 'EXPENSE'
|
form.type = p.type === 'INCOME' ? 'INCOME' : 'EXPENSE'
|
||||||
@@ -749,8 +751,9 @@ function applyParsed(p) {
|
|||||||
if (p.date) form.entryDate = p.date
|
if (p.date) form.entryDate = p.date
|
||||||
categoryMajor.value = ''
|
categoryMajor.value = ''
|
||||||
pasteDetected.value = null
|
pasteDetected.value = null
|
||||||
pasteOpen.value = false
|
pasteModalOpen.value = false
|
||||||
pasteText.value = ''
|
pasteText.value = ''
|
||||||
|
pasteError.value = ''
|
||||||
}
|
}
|
||||||
async function parseAndApply(text, { silent } = {}) {
|
async function parseAndApply(text, { silent } = {}) {
|
||||||
const t = (text || '').trim()
|
const t = (text || '').trim()
|
||||||
@@ -767,10 +770,9 @@ async function parseAndApply(text, { silent } = {}) {
|
|||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
// 추가 팝업 열 때 클립보드 자동 감지(권한 막히면 조용히 무시)
|
// 추가 팝업 열 때 클립보드 자동 감지(권한 막히면 조용히 무시) → 버튼 배지로 안내
|
||||||
async function tryClipboardOnOpen() {
|
async function tryClipboardOnOpen() {
|
||||||
pasteDetected.value = null
|
pasteDetected.value = null
|
||||||
pasteOpen.value = false
|
|
||||||
pasteText.value = ''
|
pasteText.value = ''
|
||||||
try {
|
try {
|
||||||
const txt = await navigator.clipboard.readText()
|
const txt = await navigator.clipboard.readText()
|
||||||
@@ -779,19 +781,27 @@ async function tryClipboardOnOpen() {
|
|||||||
// 클립보드 접근 불가 — 버튼으로 수동
|
// 클립보드 접근 불가 — 버튼으로 수동
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// '붙여넣기' 버튼: 클립보드 읽어 바로 적용, 실패 시 수동 입력칸
|
// '문자·푸시' 버튼 → 모달 열기 + 클립보드 자동 읽기
|
||||||
async function pasteFromClipboard() {
|
async function openPasteModal() {
|
||||||
|
pasteError.value = ''
|
||||||
|
pasteModalOpen.value = true
|
||||||
|
if (!pasteText.value) {
|
||||||
try {
|
try {
|
||||||
const txt = await navigator.clipboard.readText()
|
const txt = await navigator.clipboard.readText()
|
||||||
const ok = await parseAndApply(txt, { silent: false })
|
if (txt) {
|
||||||
if (!ok) pasteOpen.value = true
|
pasteText.value = txt
|
||||||
|
await parseAndApply(txt, { silent: true })
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
pasteOpen.value = true
|
// 클립보드 접근 불가 — 수동 붙여넣기
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 모달에서 분석·채우기
|
||||||
async function analyzePasted() {
|
async function analyzePasted() {
|
||||||
|
pasteError.value = ''
|
||||||
const ok = await parseAndApply(pasteText.value, { silent: false })
|
const ok = await parseAndApply(pasteText.value, { silent: false })
|
||||||
if (!ok) formError.value = '거래 내용을 인식하지 못했습니다.'
|
if (!ok) pasteError.value = '거래 내용을 인식하지 못했습니다. 금액과 승인/입금 등이 포함됐는지 확인하세요.'
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
@@ -947,17 +957,23 @@ onMounted(async () => {
|
|||||||
<h2>{{ editId ? '내역 수정' : '내역 추가' }}</h2>
|
<h2>{{ editId ? '내역 수정' : '내역 추가' }}</h2>
|
||||||
|
|
||||||
<form class="entry-form" @submit.prevent="submit">
|
<form class="entry-form" @submit.prevent="submit">
|
||||||
<!-- 영수증 OCR (온디바이스) -->
|
<!-- 영수증 스캔 · 문자/푸시 (같은 행 상단) -->
|
||||||
<div v-if="!isRepayment" class="receipt-box">
|
<div v-if="!isRepayment" class="receipt-box">
|
||||||
<input
|
<input
|
||||||
ref="receiptInput" type="file" accept="image/*"
|
ref="receiptInput" type="file" accept="image/*"
|
||||||
:capture="webCapture ? 'environment' : undefined"
|
:capture="webCapture ? 'environment' : undefined"
|
||||||
class="receipt-input" @change="onReceiptFile"
|
class="receipt-input" @change="onReceiptFile"
|
||||||
/>
|
/>
|
||||||
|
<div class="rb-actions">
|
||||||
<button
|
<button
|
||||||
type="button" class="receipt-btn"
|
type="button" class="receipt-btn"
|
||||||
:disabled="submitting || ocrRunning" @click="pickReceipt"
|
:disabled="submitting || ocrRunning" @click="pickReceipt"
|
||||||
>📷 영수증 스캔</button>
|
>📷 영수증 스캔</button>
|
||||||
|
<button
|
||||||
|
type="button" class="paste-btn" :class="{ detected: pasteDetected }"
|
||||||
|
:disabled="submitting" @click="openPasteModal"
|
||||||
|
>📋 문자·푸시<span v-if="pasteDetected" class="pb-dot">●</span></button>
|
||||||
|
</div>
|
||||||
<span v-if="ocrRunning" class="receipt-status">영수증 인식 중…</span>
|
<span v-if="ocrRunning" class="receipt-status">영수증 인식 중…</span>
|
||||||
<span v-else-if="ocrResult" class="receipt-status ok">
|
<span v-else-if="ocrResult" class="receipt-status ok">
|
||||||
<template v-if="ocrResult.amount">{{ won(ocrResult.amount) }}원</template>
|
<template v-if="ocrResult.amount">{{ won(ocrResult.amount) }}원</template>
|
||||||
@@ -967,26 +983,9 @@ onMounted(async () => {
|
|||||||
자동 입력됨
|
자동 입력됨
|
||||||
</span>
|
</span>
|
||||||
<span v-else class="receipt-hint">사진에서 금액·날짜·상호를 자동 입력</span>
|
<span v-else class="receipt-hint">사진에서 금액·날짜·상호를 자동 입력</span>
|
||||||
<!-- 문자/푸시 붙여넣기 (영수증 스캔과 같은 행, 우측) -->
|
|
||||||
<button
|
|
||||||
type="button" class="paste-btn"
|
|
||||||
:disabled="submitting" @click="pasteFromClipboard"
|
|
||||||
>📋 문자·푸시</button>
|
|
||||||
<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>
|
||||||
|
|
||||||
<!-- 붙여넣기 감지 결과 / 수동 입력 -->
|
|
||||||
<div v-if="!isRepayment && (pasteDetected || pasteOpen)" class="paste-extra">
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<!-- 신규 추가 시: 자주 쓰는 내역 불러오기(모달 피커) -->
|
<!-- 신규 추가 시: 자주 쓰는 내역 불러오기(모달 피커) -->
|
||||||
<button
|
<button
|
||||||
v-if="!editId && quickEntries.length"
|
v-if="!editId && quickEntries.length"
|
||||||
@@ -1200,6 +1199,28 @@ onMounted(async () => {
|
|||||||
</div>
|
</div>
|
||||||
</Transition>
|
</Transition>
|
||||||
</Teleport>
|
</Teleport>
|
||||||
|
|
||||||
|
<!-- 문자/푸시 붙여넣기 (모달) -->
|
||||||
|
<Teleport to="body">
|
||||||
|
<Transition name="fade">
|
||||||
|
<div v-if="pasteModalOpen" class="modal-backdrop quick-picker-backdrop" @click.self="pasteModalOpen = false">
|
||||||
|
<div class="modal paste-modal" role="dialog" aria-modal="true">
|
||||||
|
<button class="close" type="button" @click="pasteModalOpen = false">×</button>
|
||||||
|
<h2>문자·푸시 붙여넣기</h2>
|
||||||
|
<p class="pm-hint">문자/푸시 내용을 붙여넣고 분석하면 구분·금액·메모·날짜가 자동 입력됩니다.</p>
|
||||||
|
<textarea v-model="pasteText" rows="5" class="pm-text" placeholder="여기에 붙여넣기 (예: [Web발신] 신한카드 승인 12,000원 스타벅스 06/27)"></textarea>
|
||||||
|
<div v-if="pasteDetected" 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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</Teleport>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -1582,14 +1603,19 @@ button.primary {
|
|||||||
}
|
}
|
||||||
.receipt-box {
|
.receipt-box {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
flex-direction: column;
|
||||||
flex-wrap: wrap;
|
align-items: flex-start;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
padding: 0.5rem 0.6rem;
|
padding: 0.5rem 0.6rem;
|
||||||
border: 1px dashed var(--color-border);
|
border: 1px dashed var(--color-border);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
background: var(--color-background-soft);
|
background: var(--color-background-soft);
|
||||||
}
|
}
|
||||||
|
.rb-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
.receipt-input {
|
.receipt-input {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
@@ -1848,15 +1874,12 @@ button.primary {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
/* 문자/푸시 붙여넣기 */
|
/* 문자/푸시 붙여넣기 */
|
||||||
.paste-extra {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.4rem;
|
|
||||||
}
|
|
||||||
.paste-btn {
|
.paste-btn {
|
||||||
margin-left: auto; /* 영수증 행 우측으로 */
|
display: inline-flex;
|
||||||
padding: 0.4rem 0.7rem;
|
align-items: center;
|
||||||
font-size: 0.8rem;
|
gap: 0.25rem;
|
||||||
|
padding: 0.45rem 0.85rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
background: var(--color-background);
|
background: var(--color-background);
|
||||||
@@ -1864,23 +1887,27 @@ button.primary {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
.paste-btn.detected {
|
||||||
|
border-color: hsla(160, 100%, 37%, 0.7);
|
||||||
|
}
|
||||||
|
.paste-btn .pb-dot {
|
||||||
|
color: hsla(160, 100%, 37%, 1);
|
||||||
|
font-size: 0.6rem;
|
||||||
|
}
|
||||||
.paste-detected {
|
.paste-detected {
|
||||||
font-size: 0.82rem;
|
font-size: 0.82rem;
|
||||||
background: hsla(160, 100%, 37%, 0.1);
|
background: hsla(160, 100%, 37%, 0.1);
|
||||||
border: 1px solid hsla(160, 100%, 37%, 0.4);
|
border: 1px solid hsla(160, 100%, 37%, 0.4);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
padding: 0.4rem 0.6rem;
|
padding: 0.4rem 0.6rem;
|
||||||
display: flex;
|
margin: 0.5rem 0;
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
}
|
||||||
.paste-manual {
|
.paste-modal .pm-hint {
|
||||||
display: flex;
|
font-size: 0.82rem;
|
||||||
flex-direction: column;
|
opacity: 0.7;
|
||||||
gap: 0.3rem;
|
margin: 0 0 0.6rem;
|
||||||
}
|
}
|
||||||
.paste-manual textarea {
|
.paste-modal .pm-text {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
@@ -1891,17 +1918,6 @@ button.primary {
|
|||||||
font: inherit;
|
font: inherit;
|
||||||
resize: vertical;
|
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%;
|
||||||
|
|||||||
Reference in New Issue
Block a user