feat: 홈 대시보드 + 영수증 OCR + 카드 할부 + 소수점 매수 등 UI 개선
CI / build (push) Failing after 13m56s

- 홈: 로그인 시 요약(이번달 수입/지출, 순자산, 예산대비지출 바)+바로가기, 비로그인 랜딩
- 영수증 OCR(온디바이스 Tesseract.js): 금액·날짜·상호 추출해 내역 폼 자동입력
- 내역 카드 할부(2~24개월) 입력/표시
- 투자 소수점 수량 입력(step=any)
- 계좌 관리 등록버튼 탭 라인 우측 이동, 정기거래 3단 레이아웃
- 목록 좌측 여백 제거(분류/예산/태그/계좌), 대시보드 일/주 버튼 제거, 도넛/파이 차트 잘림 수정

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
ByungCheol
2026-06-03 16:42:07 +09:00
parent 483cf755ed
commit efbe298a1f
12 changed files with 834 additions and 49 deletions
+115
View File
@@ -19,6 +19,7 @@
"pinia": "^3.0.4", "pinia": "^3.0.4",
"prismjs": "^1.30.0", "prismjs": "^1.30.0",
"sortablejs": "^1.15.7", "sortablejs": "^1.15.7",
"tesseract.js": "^7.0.0",
"vue": "^3.5.32", "vue": "^3.5.32",
"vue-router": "^5.0.4" "vue-router": "^5.0.4"
}, },
@@ -2250,6 +2251,12 @@
"url": "https://github.com/sponsors/antfu" "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": { "node_modules/boolbase": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
@@ -3274,6 +3281,12 @@
"node": ">= 6" "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": { "node_modules/ignore": {
"version": "5.3.2", "version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -3389,6 +3402,12 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/is-what": {
"version": "5.5.0", "version": "5.5.0",
"resolved": "https://registry.npmjs.org/is-what/-/is-what-5.5.0.tgz", "resolved": "https://registry.npmjs.org/is-what/-/is-what-5.5.0.tgz",
@@ -4043,6 +4062,26 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/node-releases": {
"version": "2.0.46", "version": "2.0.46",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.46.tgz", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.46.tgz",
@@ -4168,6 +4207,15 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/optionator": {
"version": "0.9.4", "version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@@ -4669,6 +4717,12 @@
"url": "https://paulmillr.com/funding/" "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": { "node_modules/rfdc": {
"version": "1.4.1", "version": "1.4.1",
"resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz",
@@ -4989,6 +5043,30 @@
"node": ">=18" "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": { "node_modules/through2": {
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/through2/-/through2-4.0.2.tgz", "resolved": "https://registry.npmjs.org/through2/-/through2-4.0.2.tgz",
@@ -5024,6 +5102,12 @@
"node": ">=6" "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": { "node_modules/tree-kill": {
"version": "1.2.2", "version": "1.2.2",
"resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
@@ -5582,12 +5666,34 @@
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
"license": "MIT" "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": { "node_modules/webpack-virtual-modules": {
"version": "0.6.2", "version": "0.6.2",
"resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz", "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz",
"integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==", "integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==",
"license": "MIT" "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": { "node_modules/which": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@@ -5747,6 +5853,15 @@
"funding": { "funding": {
"url": "https://github.com/sponsors/sindresorhus" "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": "*"
}
} }
} }
} }
+1
View File
@@ -26,6 +26,7 @@
"pinia": "^3.0.4", "pinia": "^3.0.4",
"prismjs": "^1.30.0", "prismjs": "^1.30.0",
"sortablejs": "^1.15.7", "sortablejs": "^1.15.7",
"tesseract.js": "^7.0.0",
"vue": "^3.5.32", "vue": "^3.5.32",
"vue-router": "^5.0.4" "vue-router": "^5.0.4"
}, },
+95
View File
@@ -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,
}
}
+437 -16
View File
@@ -1,33 +1,454 @@
<script setup> <script setup>
// 홈 = 대시보드 (임시: 히어로 이미지). 추후 요약 위젯으로 교체 예정. import { ref, computed, onMounted, watch } from 'vue'
import { RouterLink } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { useUiStore } from '@/stores/ui'
import { accountApi } from '@/api/accountApi'
const auth = useAuthStore()
const ui = useUiStore()
const now = new Date()
const year = now.getFullYear()
const month = now.getMonth() + 1
const monthLabel = `${year}${month}`
const summary = ref({ totalIncome: 0, totalExpense: 0, balance: 0 })
const networth = ref({ totalAssets: 0, totalLiabilities: 0, netWorth: 0 })
const budgetTotal = ref(0)
const spentTotal = ref(0)
const loading = ref(false)
const error = ref(null)
const displayName = computed(() => auth.user?.name || auth.user?.loginId || '')
// 예산 대비 지출
const budgetPct = computed(() =>
budgetTotal.value > 0 ? Math.round((spentTotal.value / budgetTotal.value) * 100) : 0,
)
const budgetLeft = computed(() => budgetTotal.value - spentTotal.value)
const budgetOver = computed(() => budgetLeft.value < 0)
function won(n) {
return (n ?? 0).toLocaleString('ko-KR')
}
// 가계부 주요 화면 바로가기
const shortcuts = [
{ to: '/account', icon: '📊', label: '대시보드', desc: '한눈에 보기' },
{ to: '/account/entries', icon: '📒', label: '가계부 내역', desc: '수입·지출 기록' },
{ to: '/account/wallets', icon: '🏦', label: '계좌 관리', desc: '자산·부채' },
{ to: '/account/budget', icon: '🎯', label: '예산 설정', desc: '예산 대비 지출' },
{ to: '/account/recurrings', icon: '🔁', label: '정기 거래', desc: '반복 수입·지출' },
{ to: '/account/categories', icon: '🗂️', label: '분류 관리', desc: '카테고리' },
]
async function load() {
if (!auth.isAuthenticated) return
loading.value = true
error.value = null
try {
const [sum, nw, status] = await Promise.all([
accountApi.summary({ year, month }),
accountApi.netWorth(),
accountApi.budgetStatus({ year, month }),
])
summary.value = sum
networth.value = nw
budgetTotal.value = (status || []).reduce((s, x) => s + (x.monthlyBudget || 0), 0)
spentTotal.value = (status || []).reduce((s, x) => s + (x.spent || 0), 0)
} catch (e) {
error.value = e.response?.data?.message || '요약을 불러오지 못했습니다.'
} finally {
loading.value = false
}
}
// 로그인/로그아웃 전환 시 갱신
watch(() => auth.isAuthenticated, (v) => {
if (v) load()
else {
summary.value = { totalIncome: 0, totalExpense: 0, balance: 0 }
networth.value = { totalAssets: 0, totalLiabilities: 0, netWorth: 0 }
budgetTotal.value = 0
spentTotal.value = 0
}
})
onMounted(load)
</script> </script>
<template> <template>
<section class="home"> <section class="home">
<div class="hero"></div> <!-- ===== 로그인: 요약 대시보드 ===== -->
<template v-if="auth.isAuthenticated">
<header class="greet">
<h1>안녕하세요, {{ displayName }} 👋</h1>
<p class="today">{{ monthLabel }} 가계부 요약</p>
</header>
<p v-if="error" class="msg error">{{ error }}</p>
<div class="cards">
<!-- 이번 수입/지출/수지 -->
<div class="card">
<div class="card-head">
<span class="card-title">이번 </span>
<RouterLink to="/account/entries" class="more">내역 </RouterLink>
</div>
<div class="stat-row">
<div class="stat">
<span class="label">수입</span>
<span class="value income">{{ won(summary.totalIncome) }}</span>
</div>
<div class="stat">
<span class="label">지출</span>
<span class="value expense">{{ won(summary.totalExpense) }}</span>
</div>
<div class="stat">
<span class="label">수지</span>
<span class="value" :class="summary.balance < 0 ? 'expense' : 'income'">{{ won(summary.balance) }}</span>
</div>
</div>
</div>
<!-- 순자산 -->
<div class="card">
<div class="card-head">
<span class="card-title">순자산</span>
<RouterLink to="/account/wallets" class="more">계좌 </RouterLink>
</div>
<div class="stat-row">
<div class="stat">
<span class="label">총자산</span>
<span class="value income">{{ won(networth.totalAssets) }}</span>
</div>
<div class="stat">
<span class="label">총부채</span>
<span class="value expense">{{ won(networth.totalLiabilities) }}</span>
</div>
<div class="stat">
<span class="label">순자산</span>
<span class="value">{{ won(networth.netWorth) }}</span>
</div>
</div>
</div>
</div>
<!-- 예산 대비 지출 -->
<div class="card budget-card">
<div class="card-head">
<span class="card-title">{{ month }} 예산 대비 지출</span>
<RouterLink to="/account/budget" class="more">예산 </RouterLink>
</div>
<template v-if="budgetTotal > 0">
<div class="budget-bar">
<div
class="budget-fill" :class="{ over: budgetOver }"
:style="{ width: Math.min(budgetPct, 100) + '%' }"
></div>
</div>
<div class="budget-info">
<span class="b-pct" :class="{ over: budgetOver }">{{ budgetPct }}%</span>
<span class="b-detail">
지출 <b class="expense">{{ won(spentTotal) }}</b> / 예산 <b>{{ won(budgetTotal) }}</b>
· <span :class="budgetOver ? 'expense' : 'income'">{{ budgetOver ? '초과' : '잔여' }} {{ won(Math.abs(budgetLeft)) }}</span>
</span>
</div>
</template>
<RouterLink v-else to="/account/budget" class="budget-empty">예산을 설정하면 지출 진행률을 있어요 </RouterLink>
</div>
<!-- 바로가기 -->
<div class="shortcuts">
<RouterLink v-for="s in shortcuts" :key="s.to" :to="s.to" class="shortcut">
<span class="sc-icon">{{ s.icon }}</span>
<span class="sc-text">
<span class="sc-label">{{ s.label }}</span>
<span class="sc-desc">{{ s.desc }}</span>
</span>
</RouterLink>
</div>
</template>
<!-- ===== 비로그인: 랜딩 ===== -->
<template v-else>
<div class="landing">
<div class="hero-card">
<h1 class="brand">Slim Budget</h1>
<p class="tagline">슬림하게 관리하는 가계부 · 자산 · 예산</p>
<div class="cta">
<button type="button" class="btn primary" @click="ui.openLogin('/account')">로그인</button>
<button type="button" class="btn" @click="ui.openSignup()">회원가입</button>
</div>
<p class="cta-note">로그인하면 나의 가계부 요약을 있어요.</p>
</div>
<ul class="features">
<li><span class="f-icon">📒</span><b>간편한 가계부</b><span>수입·지출을 빠르게 기록하고 일별로 정리</span></li>
<li><span class="f-icon">🏦</span><b>자산·부채 관리</b><span>계좌·카드·대출·투자를 한곳에서 순자산까지</span></li>
<li><span class="f-icon">🎯</span><b>예산과 분석</b><span>예산 대비 지출, 분류별 차트로 한눈에</span></li>
</ul>
</div>
</template>
</section> </section>
</template> </template>
<style scoped> <style scoped>
.home { .home {
max-width: 960px; max-width: 760px;
margin: 0 auto; margin: 0 auto;
} }
/* 임시 히어로 이미지 — public/home-hero.jpg 를 두면 표시됨. 없으면 연한 바다색 배경 */
.hero { /* ===== 로그인 대시보드 ===== */
width: 100%; .greet h1 {
min-height: 70vh; font-size: 1.4rem;
border-radius: 10px;
background-color: #bfe6fb;
background-image: url('/home-hero.jpg');
background-size: cover;
background-position: center;
background-repeat: no-repeat;
} }
@media (max-width: 768px) { .greet .today {
.hero { margin-top: 0.2rem;
min-height: 78vh; opacity: 0.65;
font-size: 0.9rem;
}
.cards {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.9rem;
margin: 1.2rem 0;
}
.card {
border: 1px solid var(--color-border);
border-radius: 10px;
padding: 1rem 1.1rem;
background: var(--color-background-soft);
}
.card-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.7rem;
}
.card-title {
font-weight: 700;
font-size: 0.95rem;
}
.more {
font-size: 0.78rem;
opacity: 0.7;
text-decoration: none;
color: var(--color-text);
}
.more:hover {
opacity: 1;
color: hsla(160, 100%, 37%, 1);
}
.stat-row {
display: flex;
justify-content: space-between;
gap: 0.5rem;
}
.stat {
display: flex;
flex-direction: column;
gap: 0.2rem;
min-width: 0;
}
.stat .label {
font-size: 0.76rem;
opacity: 0.65;
}
.stat .value {
font-size: 1rem;
font-weight: 700;
white-space: nowrap;
}
.value.income {
color: #2e7d32;
}
.value.expense {
color: #c0392b;
}
/* 예산 대비 지출 카드 */
.budget-card {
margin-bottom: 1.2rem;
}
.budget-bar {
height: 12px;
border-radius: 6px;
background: var(--color-background-mute);
overflow: hidden;
}
.budget-fill {
height: 100%;
border-radius: 6px;
background: hsla(160, 100%, 37%, 1);
transition: width 0.3s ease;
}
.budget-fill.over {
background: #c0392b;
}
.budget-info {
display: flex;
align-items: baseline;
gap: 0.6rem;
margin-top: 0.6rem;
flex-wrap: wrap;
}
.b-pct {
font-size: 1.1rem;
font-weight: 700;
color: hsla(160, 100%, 37%, 1);
}
.b-pct.over {
color: #c0392b;
}
.b-detail {
font-size: 0.82rem;
opacity: 0.8;
}
.budget-empty {
display: block;
font-size: 0.85rem;
opacity: 0.7;
text-decoration: none;
color: var(--color-text);
}
.budget-empty:hover {
color: hsla(160, 100%, 37%, 1);
}
/* 바로가기 그리드 */
.shortcuts {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.7rem;
}
.shortcut {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.8rem;
border: 1px solid var(--color-border);
border-radius: 10px;
text-decoration: none;
color: var(--color-text);
background: var(--color-background);
transition: border-color 0.15s, transform 0.1s;
}
.shortcut:hover {
border-color: hsla(160, 100%, 37%, 0.6);
transform: translateY(-1px);
}
.sc-icon {
font-size: 1.4rem;
line-height: 1;
}
.sc-text {
display: flex;
flex-direction: column;
min-width: 0;
}
.sc-label {
font-weight: 600;
font-size: 0.9rem;
}
.sc-desc {
font-size: 0.74rem;
opacity: 0.6;
}
.msg {
margin: 0.75rem 0;
}
.msg.error {
color: #c0392b;
}
/* ===== 비로그인 랜딩 ===== */
.landing {
padding: 1rem 0 2rem;
}
.hero-card {
text-align: center;
padding: 2.6rem 1.5rem;
border-radius: 14px;
background:
radial-gradient(120% 120% at 50% 0%, hsla(160, 100%, 37%, 0.18), transparent 60%),
var(--color-background-soft);
border: 1px solid var(--color-border);
}
.brand {
font-size: 2rem;
letter-spacing: -0.5px;
}
.tagline {
margin-top: 0.5rem;
opacity: 0.75;
}
.cta {
display: flex;
gap: 0.6rem;
justify-content: center;
margin-top: 1.4rem;
}
.btn {
padding: 0.6rem 1.4rem;
border: 1px solid var(--color-border);
border-radius: 8px; border-radius: 8px;
background: var(--color-background);
color: var(--color-text);
font-size: 0.95rem;
cursor: pointer;
}
.btn.primary {
border-color: hsla(160, 100%, 37%, 1);
background: hsla(160, 100%, 37%, 1);
color: #fff;
font-weight: 600;
}
.cta-note {
margin-top: 0.9rem;
font-size: 0.8rem;
opacity: 0.6;
}
.features {
list-style: none;
padding: 0;
margin: 1.6rem 0 0;
display: grid;
gap: 0.7rem;
}
.features li {
display: grid;
grid-template-columns: auto 1fr;
grid-template-rows: auto auto;
column-gap: 0.8rem;
align-items: center;
padding: 0.9rem 1rem;
border: 1px solid var(--color-border);
border-radius: 10px;
}
.features .f-icon {
grid-row: 1 / 3;
font-size: 1.6rem;
}
.features b {
font-size: 0.95rem;
}
.features li > span:last-child {
font-size: 0.82rem;
opacity: 0.65;
}
@media (max-width: 768px) {
.cards {
grid-template-columns: 1fr;
}
.shortcuts {
grid-template-columns: repeat(2, 1fr);
}
.brand {
font-size: 1.7rem;
} }
} }
</style> </style>
+3 -3
View File
@@ -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_C = 2 * Math.PI * PIE_R
const PIE_COLORS = ['#3b82a6', '#e67e22', '#2e7d32', '#9b59b6', '#c0392b', '#16a085', '#f39c12', '#8e44ad', '#27ae60', '#d35400', '#2980b9', '#7f8c8d'] 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)) const catTotal = computed(() => catData.value.reduce((s, d) => s + d.total, 0))
@@ -350,8 +350,6 @@ onMounted(async () => {
<div class="panel-head"> <div class="panel-head">
<h2>기간별 예산 대비 지출</h2> <h2>기간별 예산 대비 지출</h2>
<div class="unit-tabs"> <div class="unit-tabs">
<button type="button" :class="{ active: unit === 'DAY' }" @click="setUnit('DAY')">일별</button>
<button type="button" :class="{ active: unit === 'WEEK' }" @click="setUnit('WEEK')">주별</button>
<button type="button" :class="{ active: unit === 'MONTH' }" @click="setUnit('MONTH')">월별</button> <button type="button" :class="{ active: unit === 'MONTH' }" @click="setUnit('MONTH')">월별</button>
<button type="button" :class="{ active: unit === 'YEAR' }" @click="setUnit('YEAR')">년별</button> <button type="button" :class="{ active: unit === 'YEAR' }" @click="setUnit('YEAR')">년별</button>
</div> </div>
@@ -498,6 +496,7 @@ h2 {
width: 120px; width: 120px;
height: 120px; height: 120px;
flex-shrink: 0; flex-shrink: 0;
overflow: visible;
} }
.donut-pct { .donut-pct {
font-size: 20px; font-size: 20px;
@@ -679,6 +678,7 @@ h2 {
height: auto; height: auto;
aspect-ratio: 1 / 1; aspect-ratio: 1 / 1;
flex-shrink: 0; flex-shrink: 0;
overflow: visible;
} }
.pie-seg { .pie-seg {
cursor: pointer; cursor: pointer;
+2
View File
@@ -133,6 +133,8 @@ button.danger {
} }
.tag-list { .tag-list {
list-style: none; list-style: none;
padding-left: 0;
margin: 0;
} }
.tag-row { .tag-row {
display: flex; display: flex;
+123 -3
View File
@@ -2,6 +2,7 @@
import { computed, onMounted, reactive, ref } from 'vue' import { computed, onMounted, reactive, ref } from 'vue'
import { accountApi } from '@/api/accountApi' import { accountApi } from '@/api/accountApi'
import IconBtn from '@/components/ui/IconBtn.vue' import IconBtn from '@/components/ui/IconBtn.vue'
import { scanReceipt } from '@/utils/receiptOcr'
const now = new Date() const now = new Date()
const year = ref(now.getFullYear()) const year = ref(now.getFullYear())
@@ -40,12 +41,53 @@ function resetFilters() {
// 추가/수정 모달 // 추가/수정 모달
const formOpen = ref(false) const formOpen = ref(false)
const editId = ref(null) 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') 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 liabilityWallets = computed(() => wallets.value.filter((w) => w.type === 'LOAN' || w.type === 'CARD'))
const submitting = ref(false) const submitting = ref(false)
const formError = ref(null) 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([]) const wallets = ref([])
async function loadWallets() { async function loadWallets() {
@@ -227,7 +269,7 @@ function todayStr() {
function openCreate() { function openCreate() {
editId.value = null 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 = [] selectedTagIds.value = []
cancelAddCategory() cancelAddCategory()
formError.value = null formError.value = null
@@ -245,6 +287,7 @@ function openEdit(e) {
walletId: e.walletId || '', walletId: e.walletId || '',
toWalletKind: walletKindOf(e.toWalletId), toWalletKind: walletKindOf(e.toWalletId),
toWalletId: e.toWalletId || '', toWalletId: e.toWalletId || '',
installmentMonths: e.installmentMonths || '',
}) })
// 태그 이름 → id 매핑 (현재 태그 목록 기준) // 태그 이름 → id 매핑 (현재 태그 목록 기준)
const nameToId = {} const nameToId = {}
@@ -319,6 +362,7 @@ async function submit() {
memo: form.memo || null, memo: form.memo || null,
walletId: form.walletId || null, walletId: form.walletId || null,
toWalletId: form.type === 'TRANSFER' ? form.toWalletId || null : null, toWalletId: form.type === 'TRANSFER' ? form.toWalletId || null : null,
installmentMonths: showInstallment.value && Number(form.installmentMonths) >= 2 ? Number(form.installmentMonths) : null,
tagIds: selectedTagIds.value, tagIds: selectedTagIds.value,
} }
try { try {
@@ -444,7 +488,8 @@ onMounted(async () => {
<IconBtn icon="trash" title="삭제" variant="danger" size="sm" @click="remove(e)" /> <IconBtn icon="trash" title="삭제" variant="danger" size="sm" @click="remove(e)" />
</span> </span>
</div> </div>
<div v-if="e.memo || (e.tags && e.tags.length)" class="ei-line2"> <div v-if="e.memo || e.installmentMonths > 1 || (e.tags && e.tags.length)" class="ei-line2">
<span v-if="e.installmentMonths > 1" class="ei-install">{{ e.installmentMonths }}개월 할부 · {{ won(Math.round(e.amount / e.installmentMonths)) }}</span>
<span v-if="e.memo" class="ei-memo">{{ e.memo }}</span> <span v-if="e.memo" class="ei-memo">{{ e.memo }}</span>
<span v-for="t in e.tags" :key="t" class="row-tag">{{ t }}</span> <span v-for="t in e.tags" :key="t" class="row-tag">{{ t }}</span>
</div> </div>
@@ -463,6 +508,26 @@ 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">
<input
ref="receiptInput" type="file" accept="image/*"
class="receipt-input" @change="onReceiptFile"
/>
<button
type="button" class="receipt-btn"
:disabled="submitting || ocrRunning" @click="pickReceipt"
>📷 영수증 스캔</button>
<span v-if="ocrRunning" class="receipt-status">인식 {{ ocrProgress }}%</span>
<span v-else-if="ocrResult" class="receipt-status ok">
<template v-if="ocrResult.amount">{{ won(ocrResult.amount) }}</template>
<template v-if="ocrResult.date"> · {{ ocrResult.date }}</template>
<template v-if="ocrResult.store"> · {{ ocrResult.store }}</template>
자동 입력됨
</span>
<span v-else class="receipt-hint">사진에서 금액·날짜·상호를 자동 입력</span>
</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"> <select v-model="form.type" :disabled="submitting">
@@ -487,6 +552,14 @@ onMounted(async () => {
</option> </option>
</select> </select>
</label> </label>
<!-- 카드 지출: 할부 개월수 (일시불 또는 2~24개월) -->
<label v-if="showInstallment">할부
<select v-model="form.installmentMonths" :disabled="submitting">
<option value="">일시불</option>
<option v-for="m in 23" :key="m + 1" :value="m + 1">{{ m + 1 }}개월</option>
</select>
<small v-if="installmentMonthly" class="install-hint"> {{ won(installmentMonthly) }}</small>
</label>
<template v-if="form.type === 'TRANSFER'"> <template v-if="form.type === 'TRANSFER'">
<label>입금 종류 <label>입금 종류
<select v-model="form.toWalletKind" :disabled="submitting" @change="onToWalletKindChange"> <select v-model="form.toWalletKind" :disabled="submitting" @change="onToWalletKindChange">
@@ -829,6 +902,40 @@ button.primary {
flex-direction: column; flex-direction: column;
gap: 0.6rem; gap: 0.6rem;
} }
.receipt-box {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.5rem;
padding: 0.5rem 0.6rem;
border: 1px dashed var(--color-border);
border-radius: 6px;
background: var(--color-background-soft);
}
.receipt-input {
display: none;
}
.receipt-btn {
padding: 0.4rem 0.7rem;
font-size: 0.85rem;
border: 1px solid hsla(160, 100%, 37%, 0.6);
border-radius: 4px;
background: var(--color-background);
color: hsla(160, 100%, 37%, 1);
cursor: pointer;
white-space: nowrap;
}
.receipt-hint {
font-size: 0.76rem;
opacity: 0.6;
}
.receipt-status {
font-size: 0.78rem;
opacity: 0.85;
}
.receipt-status.ok {
color: hsla(160, 100%, 37%, 1);
}
.entry-form label { .entry-form label {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -897,6 +1004,19 @@ button.primary {
font-size: 0.75rem; font-size: 0.75rem;
color: hsla(160, 100%, 37%, 1); color: hsla(160, 100%, 37%, 1);
} }
.install-hint {
font-size: 0.75rem;
opacity: 0.65;
margin-top: 0.1rem;
}
.ei-install {
font-size: 0.74rem;
padding: 0.05rem 0.4rem;
border: 1px solid hsla(160, 100%, 37%, 0.5);
border-radius: 3px;
color: hsla(160, 100%, 37%, 1);
white-space: nowrap;
}
.row-wallet { .row-wallet {
margin-right: 0.35rem; margin-right: 0.35rem;
font-size: 0.75rem; font-size: 0.75rem;
+18 -8
View File
@@ -265,6 +265,7 @@ onBeforeUnmount(() => sortable?.destroy())
</div> </div>
</div> </div>
<div class="tabbar">
<div class="tabs"> <div class="tabs">
<button <button
v-for="t in TABS" v-for="t in TABS"
@@ -274,9 +275,7 @@ onBeforeUnmount(() => sortable?.destroy())
@click="activeType = t.key" @click="activeType = t.key"
>{{ t.label }}</button> >{{ t.label }}</button>
</div> </div>
<IconBtn class="tab-add" icon="plus" title="추가" variant="primary" @click="openCreate" />
<div class="toolbar">
<IconBtn icon="plus" title="추가" variant="primary" @click="openCreate" />
</div> </div>
<p v-if="error" class="msg error">{{ error }}</p> <p v-if="error" class="msg error">{{ error }}</p>
@@ -434,11 +433,23 @@ button.primary {
.nw-card .value.debt { .nw-card .value.debt {
color: #c0392b; color: #c0392b;
} }
.tabbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
border-bottom: 1px solid var(--color-border);
margin-bottom: 1rem;
}
.tabs { .tabs {
display: flex; display: flex;
gap: 0.25rem; gap: 0.25rem;
border-bottom: 1px solid var(--color-border); flex: 1;
margin-bottom: 1rem; min-width: 0;
overflow-x: auto;
}
.tab-add {
flex-shrink: 0;
} }
.tabs button { .tabs button {
border: 0; border: 0;
@@ -452,11 +463,10 @@ button.primary {
color: hsla(160, 100%, 37%, 1); color: hsla(160, 100%, 37%, 1);
font-weight: 600; font-weight: 600;
} }
.toolbar {
margin-bottom: 0.75rem;
}
.wallet-list { .wallet-list {
list-style: none; list-style: none;
padding-left: 0;
margin: 0;
} }
.wallet-item { .wallet-item {
border-bottom: 1px solid var(--color-border); border-bottom: 1px solid var(--color-border);
+2
View File
@@ -468,6 +468,8 @@ button.primary {
} }
.status-list { .status-list {
list-style: none; list-style: none;
padding-left: 0;
margin: 0;
} }
.status-row { .status-row {
padding: 0.85rem 0; padding: 0.85rem 0;
+2
View File
@@ -221,6 +221,8 @@ button.danger {
} }
.cat-list { .cat-list {
list-style: none; list-style: none;
padding-left: 0;
margin: 0;
} }
.cat-row { .cat-row {
display: flex; display: flex;
+8 -4
View File
@@ -30,6 +30,10 @@ const tradesByHolding = reactive({})
function won(n) { function won(n) {
return (n ?? 0).toLocaleString('ko-KR') return (n ?? 0).toLocaleString('ko-KR')
} }
// 수량(소수점 매매 지원): 최대 6자리, 불필요한 0 제거
function shares(n) {
return Number(n ?? 0).toLocaleString('ko-KR', { maximumFractionDigits: 6 })
}
function todayStr() { function todayStr() {
const d = new Date() const d = new Date()
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}` return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
@@ -186,7 +190,7 @@ onMounted(load)
<div> <div>
<div class="h-name">{{ h.name }}<span v-if="h.ticker" class="h-ticker">{{ h.ticker }}</span></div> <div class="h-name">{{ h.name }}<span v-if="h.ticker" class="h-ticker">{{ h.ticker }}</span></div>
<div class="h-sub"> <div class="h-sub">
{{ won(h.quantity) }} · 평단 {{ won(h.avgPrice) }} {{ shares(h.quantity) }} · 평단 {{ won(h.avgPrice) }}
<template v-if="h.currentPrice != null"> · 현재가 {{ won(h.currentPrice) }}</template> <template v-if="h.currentPrice != null"> · 현재가 {{ won(h.currentPrice) }}</template>
<span v-else class="noprice"> · 현재가 미입력</span> <span v-else class="noprice"> · 현재가 미입력</span>
</div> </div>
@@ -213,7 +217,7 @@ onMounted(load)
<li v-for="t in tradesByHolding[h.id]" :key="t.id" class="trade-row"> <li v-for="t in tradesByHolding[h.id]" :key="t.id" class="trade-row">
<span class="t-date">{{ tDate(t.tradeDate) }}</span> <span class="t-date">{{ tDate(t.tradeDate) }}</span>
<span class="t-type" :class="t.tradeType === 'SELL' ? 'sell' : 'buy'">{{ t.tradeType === 'SELL' ? '매도' : '매수' }}</span> <span class="t-type" :class="t.tradeType === 'SELL' ? 'sell' : 'buy'">{{ t.tradeType === 'SELL' ? '매도' : '매수' }}</span>
<span class="t-qty">{{ won(t.quantity) }} × {{ won(t.price) }}</span> <span class="t-qty">{{ shares(t.quantity) }} × {{ won(t.price) }}</span>
<span class="t-fee" v-if="t.fee">수수료 {{ won(t.fee) }}</span> <span class="t-fee" v-if="t.fee">수수료 {{ won(t.fee) }}</span>
<span class="t-amt">{{ won(t.amount) }}</span> <span class="t-amt">{{ won(t.amount) }}</span>
<IconBtn class="t-del" icon="trash" title="매매 삭제" variant="danger" size="sm" @click="removeTrade(t, h.id)" /> <IconBtn class="t-del" icon="trash" title="매매 삭제" variant="danger" size="sm" @click="removeTrade(t, h.id)" />
@@ -261,10 +265,10 @@ onMounted(load)
</select> </select>
</label> </label>
<label>거래일<input v-model="tForm.tradeDate" type="date" :disabled="tSubmitting" /></label> <label>거래일<input v-model="tForm.tradeDate" type="date" :disabled="tSubmitting" /></label>
<label>수량()<input v-model.number="tForm.quantity" type="number" min="1" :disabled="tSubmitting" /></label> <label>수량()<input v-model.number="tForm.quantity" type="number" min="0" step="any" inputmode="decimal" placeholder="예: 0.5 / 1.25 (소수점 가능)" :disabled="tSubmitting" /></label>
<label>단가()<input v-model.number="tForm.price" type="number" min="0" :disabled="tSubmitting" /></label> <label>단가()<input v-model.number="tForm.price" type="number" min="0" :disabled="tSubmitting" /></label>
<label>수수료/세금<input v-model.number="tForm.fee" type="number" min="0" placeholder="(선택)" :disabled="tSubmitting" /></label> <label>수수료/세금<input v-model.number="tForm.fee" type="number" min="0" placeholder="(선택)" :disabled="tSubmitting" /></label>
<p v-if="tForm.tradeType === 'SELL'" class="pf-hint">보유 {{ won(tradeHolding?.quantity) }} · 평단 {{ won(tradeHolding?.avgPrice) }}</p> <p v-if="tForm.tradeType === 'SELL'" class="pf-hint">보유 {{ shares(tradeHolding?.quantity) }} · 평단 {{ won(tradeHolding?.avgPrice) }}</p>
<p v-if="tError" class="pf-msg err">{{ tError }}</p> <p v-if="tError" class="pf-msg err">{{ tError }}</p>
<div class="pf-buttons"> <div class="pf-buttons">
<IconBtn icon="close" title="취소" @click="tradeModal = false" /> <IconBtn icon="close" title="취소" @click="tradeModal = false" />
+19 -6
View File
@@ -191,11 +191,10 @@ onMounted(load)
<span class="type-badge" :class="r.type.toLowerCase()">{{ typeLabel(r.type) }}</span> <span class="type-badge" :class="r.type.toLowerCase()">{{ typeLabel(r.type) }}</span>
<span v-if="!r.active" class="off">중지</span> <span v-if="!r.active" class="off">중지</span>
</div> </div>
<span class="sub"> <div class="sub">
{{ freqLabel(r) }} · {{ won(r.amount) }} {{ freqLabel(r) }} · {{ won(r.amount) }}<template v-if="r.category"> · {{ r.category }}</template>
<template v-if="r.category"> · {{ r.category }}</template> </div>
<template v-if="r.nextDate"> · 다음 {{ r.nextDate }}</template> <div v-if="r.nextDate" class="next">다음 {{ r.nextDate }}</div>
</span>
</div> </div>
<div class="actions"> <div class="actions">
<IconBtn icon="edit" title="수정" size="sm" @click="openEdit(r)" /> <IconBtn icon="edit" title="수정" size="sm" @click="openEdit(r)" />
@@ -328,14 +327,21 @@ button.primary {
} }
.recur-list { .recur-list {
list-style: none; list-style: none;
padding-left: 0;
margin: 0;
} }
.recur-row { .recur-row {
display: flex; display: flex;
align-items: center; align-items: flex-start;
justify-content: space-between; justify-content: space-between;
gap: 0.6rem;
padding: 0.7rem 0; padding: 0.7rem 0;
border-bottom: 1px solid var(--color-border); border-bottom: 1px solid var(--color-border);
} }
.info {
flex: 1;
min-width: 0;
}
.recur-row.inactive { .recur-row.inactive {
opacity: 0.55; opacity: 0.55;
} }
@@ -368,10 +374,17 @@ button.primary {
.sub { .sub {
font-size: 0.83rem; font-size: 0.83rem;
opacity: 0.75; opacity: 0.75;
margin-top: 0.15rem;
}
.next {
font-size: 0.78rem;
opacity: 0.6;
margin-top: 0.1rem;
} }
.actions { .actions {
display: flex; display: flex;
gap: 0.4rem; gap: 0.4rem;
flex-shrink: 0;
} }
.actions button { .actions button {
padding: 0.2rem 0.55rem; padding: 0.2rem 0.55rem;