diff --git a/package-lock.json b/package-lock.json index 8ab5f16..f7983b3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "pinia": "^3.0.4", "prismjs": "^1.30.0", "sortablejs": "^1.15.7", + "tesseract.js": "^7.0.0", "vue": "^3.5.32", "vue-router": "^5.0.4" }, @@ -2250,6 +2251,12 @@ "url": "https://github.com/sponsors/antfu" } }, + "node_modules/bmp-js": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/bmp-js/-/bmp-js-0.1.0.tgz", + "integrity": "sha512-vHdS19CnY3hwiNdkaqk93DvjVLfbEcI8mys4UjuWrlX1haDmroo8o4xCzh4wD6DGV6HxRCyauwhHRqMTfERtjw==", + "license": "MIT" + }, "node_modules/boolbase": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", @@ -3274,6 +3281,12 @@ "node": ">= 6" } }, + "node_modules/idb-keyval": { + "version": "6.2.5", + "resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.2.5.tgz", + "integrity": "sha512-eKQkTnS0relYsSOYomx8ozIbmdsQCKUdhyuIaQ2DZgKuaxtyQQMkyD/wlnQN32pO3yutN1b1L8uqwcDKaJd7/Q==", + "license": "Apache-2.0" + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -3389,6 +3402,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-url": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/is-url/-/is-url-1.2.4.tgz", + "integrity": "sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==", + "license": "MIT" + }, "node_modules/is-what": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/is-what/-/is-what-5.5.0.tgz", @@ -4043,6 +4062,26 @@ "dev": true, "license": "MIT" }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, "node_modules/node-releases": { "version": "2.0.46", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.46.tgz", @@ -4168,6 +4207,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/opencollective-postinstall": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/opencollective-postinstall/-/opencollective-postinstall-2.0.3.tgz", + "integrity": "sha512-8AV/sCtuzUeTo8gQK5qDZzARrulB3egtLzFgteqB2tcT4Mw7B8Kt7JcDHmltjz6FOAHsvTevk70gZEbhM4ZS9Q==", + "license": "MIT", + "bin": { + "opencollective-postinstall": "index.js" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -4669,6 +4717,12 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/regenerator-runtime": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", + "license": "MIT" + }, "node_modules/rfdc": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", @@ -4989,6 +5043,30 @@ "node": ">=18" } }, + "node_modules/tesseract.js": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/tesseract.js/-/tesseract.js-7.0.0.tgz", + "integrity": "sha512-exPBkd+z+wM1BuMkx/Bjv43OeLBxhL5kKWsz/9JY+DXcXdiBjiAch0V49QR3oAJqCaL5qURE0vx9Eo+G5YE7mA==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "bmp-js": "^0.1.0", + "idb-keyval": "^6.2.0", + "is-url": "^1.2.4", + "node-fetch": "^2.6.9", + "opencollective-postinstall": "^2.0.3", + "regenerator-runtime": "^0.13.3", + "tesseract.js-core": "^7.0.0", + "wasm-feature-detect": "^1.8.0", + "zlibjs": "^0.3.1" + } + }, + "node_modules/tesseract.js-core": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/tesseract.js-core/-/tesseract.js-core-7.0.0.tgz", + "integrity": "sha512-WnNH518NzmbSq9zgTPeoF8c+xmilS8rFIl1YKbk/ptuuc7p6cLNELNuPAzcmsYw450ca6bLa8j3t0VAtq435Vw==", + "license": "Apache-2.0" + }, "node_modules/through2": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/through2/-/through2-4.0.2.tgz", @@ -5024,6 +5102,12 @@ "node": ">=6" } }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, "node_modules/tree-kill": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", @@ -5582,12 +5666,34 @@ "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", "license": "MIT" }, + "node_modules/wasm-feature-detect": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/wasm-feature-detect/-/wasm-feature-detect-1.8.0.tgz", + "integrity": "sha512-zksaLKM2fVlnB5jQQDqKXXwYHLQUVH9es+5TOOHwGOVJOCeRBCiPjwSg+3tN2AdTCzjgli4jijCH290kXb/zWQ==", + "license": "Apache-2.0" + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, "node_modules/webpack-virtual-modules": { "version": "0.6.2", "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz", "integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==", "license": "MIT" }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -5747,6 +5853,15 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zlibjs": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/zlibjs/-/zlibjs-0.3.1.tgz", + "integrity": "sha512-+J9RrgTKOmlxFSDHo0pI1xM6BLVUv+o0ZT9ANtCxGkjIVCCUdx9alUF8Gm+dGLKbkkkidWIHFDZHDMpfITt4+w==", + "license": "MIT", + "engines": { + "node": "*" + } } } } diff --git a/package.json b/package.json index 51f7f0c..ba673b0 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "pinia": "^3.0.4", "prismjs": "^1.30.0", "sortablejs": "^1.15.7", + "tesseract.js": "^7.0.0", "vue": "^3.5.32", "vue-router": "^5.0.4" }, diff --git a/src/utils/receiptOcr.js b/src/utils/receiptOcr.js new file mode 100644 index 0000000..cb28977 --- /dev/null +++ b/src/utils/receiptOcr.js @@ -0,0 +1,95 @@ +// 영수증 OCR (온디바이스, Tesseract.js). 서버·API키 불필요. +// 이미지 → 텍스트 인식 → 금액·날짜·상호 추출. +import Tesseract from 'tesseract.js' + +// 합계 금액을 가리키는 키워드 (우선순위 높은 순) +const TOTAL_KEYWORDS = [ + '받을금액', '받을 금액', '결제금액', '결제 금액', '합계금액', '합 계', '합계', + '총액', '총 액', '총구매액', '판매금액', '결제대상금액', '청구금액', '승인금액', + 'total', 'TOTAL', +] +// 합계로 오인하기 쉬운(제외할) 키워드 +const EXCLUDE_KEYWORDS = ['부가세', '면세', '과세', '봉사료', '거스름', '받은금액', '현금', '잔액', '포인트'] + +// 문자열에서 숫자(천단위 콤마 포함)들을 추출 → 정수 배열 +function numbersIn(line) { + const out = [] + const re = /\d{1,3}(?:,\d{3})+|\d{3,}/g + let m + while ((m = re.exec(line)) !== null) { + const n = parseInt(m[0].replace(/,/g, ''), 10) + if (!Number.isNaN(n)) out.push(n) + } + return out +} + +// 합계 금액 추정 +function extractAmount(lines) { + // 1) 합계 키워드가 있는 줄에서 금액 찾기 (제외 키워드 줄은 건너뜀) + for (const kw of TOTAL_KEYWORDS) { + for (const line of lines) { + if (!line.includes(kw)) continue + if (EXCLUDE_KEYWORDS.some((ex) => line.includes(ex))) continue + const nums = numbersIn(line) + if (nums.length) return Math.max(...nums) + } + } + // 2) 폴백: 전체에서 가장 큰 금액(100원 이상) + const all = lines.flatMap(numbersIn).filter((n) => n >= 100 && n < 100_000_000) + return all.length ? Math.max(...all) : null +} + +// 거래일 추정 (YYYY-MM-DD 반환) +function extractDate(text) { + // 4자리 연도: 2024-01-05 / 2024.01.05 / 2024년 1월 5일 / 2024/1/5 + let m = text.match(/(20\d{2})\s*[.\-/년]\s*(\d{1,2})\s*[.\-/월]\s*(\d{1,2})/) + if (m) return fmt(m[1], m[2], m[3]) + // 2자리 연도: 24.01.05 + m = text.match(/(\d{2})\s*[.\-/]\s*(\d{1,2})\s*[.\-/]\s*(\d{1,2})/) + if (m) return fmt('20' + m[1], m[2], m[3]) + return null +} +function fmt(y, mo, d) { + const mm = String(Number(mo)).padStart(2, '0') + const dd = String(Number(d)).padStart(2, '0') + if (Number(mo) < 1 || Number(mo) > 12 || Number(d) < 1 || Number(d) > 31) return null + return `${y}-${mm}-${dd}` +} + +// 상호(가게 이름) 추정 — 상단의 한글 포함, 숫자/키워드 없는 첫 줄 +function extractStore(lines) { + for (const line of lines.slice(0, 6)) { + const t = line.trim() + if (t.length < 2 || t.length > 30) continue + if (/\d/.test(t)) continue + if (!/[가-힣]/.test(t)) continue + if (TOTAL_KEYWORDS.some((k) => t.includes(k))) continue + if (['영수증', '주소', '사업자', '대표', 'TEL', '전화'].some((k) => t.includes(k))) continue + return t + } + return null +} + +/** + * 영수증 이미지에서 금액·날짜·상호 추출. + * @param {File|Blob|string} image 파일 또는 dataURL + * @param {(p:number)=>void} onProgress 0~100 진행률 콜백 + * @returns {Promise<{amount:number|null, date:string|null, store:string|null, text:string}>} + */ +export async function scanReceipt(image, onProgress) { + const { data } = await Tesseract.recognize(image, 'kor+eng', { + logger: (msg) => { + if (msg.status === 'recognizing text' && typeof msg.progress === 'number') { + onProgress?.(Math.round(msg.progress * 100)) + } + }, + }) + const text = data.text || '' + const lines = text.split('\n').map((l) => l.trim()).filter(Boolean) + return { + amount: extractAmount(lines), + date: extractDate(text), + store: extractStore(lines), + text, + } +} diff --git a/src/views/HomeView.vue b/src/views/HomeView.vue index 7c39857..4756bd6 100644 --- a/src/views/HomeView.vue +++ b/src/views/HomeView.vue @@ -1,33 +1,454 @@ diff --git a/src/views/account/AccountDashboardView.vue b/src/views/account/AccountDashboardView.vue index 612efd7..734a8de 100644 --- a/src/views/account/AccountDashboardView.vue +++ b/src/views/account/AccountDashboardView.vue @@ -90,7 +90,7 @@ function hideTip() { } /* ===== 분류별 파이차트 ===== */ -const PIE_R = 52 +const PIE_R = 46 // viewBox 120 기준, 선두께 20(hover 24) 포함해도 잘리지 않도록 (46+12=58 ≤ 60) const PIE_C = 2 * Math.PI * PIE_R const PIE_COLORS = ['#3b82a6', '#e67e22', '#2e7d32', '#9b59b6', '#c0392b', '#16a085', '#f39c12', '#8e44ad', '#27ae60', '#d35400', '#2980b9', '#7f8c8d'] const catTotal = computed(() => catData.value.reduce((s, d) => s + d.total, 0)) @@ -350,8 +350,6 @@ onMounted(async () => {

기간별 예산 대비 지출

- -
@@ -498,6 +496,7 @@ h2 { width: 120px; height: 120px; flex-shrink: 0; + overflow: visible; } .donut-pct { font-size: 20px; @@ -679,6 +678,7 @@ h2 { height: auto; aspect-ratio: 1 / 1; flex-shrink: 0; + overflow: visible; } .pie-seg { cursor: pointer; diff --git a/src/views/account/AccountTagView.vue b/src/views/account/AccountTagView.vue index e13b57f..f1a75d0 100644 --- a/src/views/account/AccountTagView.vue +++ b/src/views/account/AccountTagView.vue @@ -133,6 +133,8 @@ button.danger { } .tag-list { list-style: none; + padding-left: 0; + margin: 0; } .tag-row { display: flex; diff --git a/src/views/account/AccountView.vue b/src/views/account/AccountView.vue index 156d36a..5c202e1 100644 --- a/src/views/account/AccountView.vue +++ b/src/views/account/AccountView.vue @@ -2,6 +2,7 @@ import { computed, onMounted, reactive, ref } from 'vue' import { accountApi } from '@/api/accountApi' import IconBtn from '@/components/ui/IconBtn.vue' +import { scanReceipt } from '@/utils/receiptOcr' const now = new Date() const year = ref(now.getFullYear()) @@ -40,12 +41,53 @@ function resetFilters() { // 추가/수정 모달 const formOpen = ref(false) const editId = ref(null) -const form = reactive({ entryDate: '', type: 'EXPENSE', category: '', amount: null, memo: '', walletKind: '', walletId: '', toWalletKind: '', toWalletId: '', principal: null, interest: null }) +const form = reactive({ entryDate: '', type: 'EXPENSE', category: '', amount: null, memo: '', walletKind: '', walletId: '', toWalletKind: '', toWalletId: '', principal: null, interest: null, installmentMonths: '' }) const isRepayment = computed(() => form.type === 'REPAYMENT') +// 카드 지출일 때만 할부 입력 노출 (2~24개월, 일시불은 빈값) +const showInstallment = computed(() => form.type === 'EXPENSE' && form.walletKind === 'CARD') +const installmentMonthly = computed(() => { + const m = Number(form.installmentMonths) + const amt = Number(form.amount) + return m >= 2 && amt > 0 ? Math.round(amt / m) : 0 +}) const liabilityWallets = computed(() => wallets.value.filter((w) => w.type === 'LOAN' || w.type === 'CARD')) const submitting = ref(false) const formError = ref(null) +// 영수증 OCR (온디바이스) +const receiptInput = ref(null) +const ocrRunning = ref(false) +const ocrProgress = ref(0) +const ocrResult = ref(null) // { amount, date, store } +function pickReceipt() { + ocrResult.value = null + receiptInput.value?.click() +} +async function onReceiptFile(e) { + const file = e.target.files?.[0] + e.target.value = '' // 같은 파일 재선택 허용 + if (!file) return + ocrRunning.value = true + ocrProgress.value = 0 + ocrResult.value = null + formError.value = null + try { + const r = await scanReceipt(file, (p) => (ocrProgress.value = p)) + // 추출값이 있을 때만 폼에 채움 (기존 입력 보존하되, 비어있으면 채움) + if (r.amount) form.amount = r.amount + if (r.date) form.entryDate = r.date + if (r.store && !form.memo) form.memo = r.store + ocrResult.value = { amount: r.amount, date: r.date, store: r.store } + if (!r.amount && !r.date) { + formError.value = '영수증에서 정보를 충분히 인식하지 못했습니다. 직접 입력하거나 더 선명한 사진으로 다시 시도하세요.' + } + } catch { + formError.value = '영수증 인식에 실패했습니다. 다시 시도해 주세요.' + } finally { + ocrRunning.value = false + } +} + // 계좌/카드 const wallets = ref([]) async function loadWallets() { @@ -227,7 +269,7 @@ function todayStr() { function openCreate() { editId.value = null - Object.assign(form, { entryDate: todayStr(), type: 'EXPENSE', category: '', amount: null, memo: '', walletKind: '', walletId: '', toWalletKind: '', toWalletId: '', principal: null, interest: null }) + Object.assign(form, { entryDate: todayStr(), type: 'EXPENSE', category: '', amount: null, memo: '', walletKind: '', walletId: '', toWalletKind: '', toWalletId: '', principal: null, interest: null, installmentMonths: '' }) selectedTagIds.value = [] cancelAddCategory() formError.value = null @@ -245,6 +287,7 @@ function openEdit(e) { walletId: e.walletId || '', toWalletKind: walletKindOf(e.toWalletId), toWalletId: e.toWalletId || '', + installmentMonths: e.installmentMonths || '', }) // 태그 이름 → id 매핑 (현재 태그 목록 기준) const nameToId = {} @@ -319,6 +362,7 @@ async function submit() { memo: form.memo || null, walletId: form.walletId || null, toWalletId: form.type === 'TRANSFER' ? form.toWalletId || null : null, + installmentMonths: showInstallment.value && Number(form.installmentMonths) >= 2 ? Number(form.installmentMonths) : null, tagIds: selectedTagIds.value, } try { @@ -444,7 +488,8 @@ onMounted(async () => {
-
+
+ {{ e.installmentMonths }}개월 할부 · 월 {{ won(Math.round(e.amount / e.installmentMonths)) }} {{ e.memo }} {{ t }}
@@ -463,6 +508,26 @@ onMounted(async () => {

{{ editId ? '내역 수정' : '내역 추가' }}

+ +
+ + + 인식 중… {{ ocrProgress }}% + + + + + 자동 입력됨 + + 사진에서 금액·날짜·상호를 자동 입력 +
+ + +