- 홈: 로그인 시 요약(이번달 수입/지출, 순자산, 예산대비지출 바)+바로가기, 비로그인 랜딩 - 영수증 OCR(온디바이스 Tesseract.js): 금액·날짜·상호 추출해 내역 폼 자동입력 - 내역 카드 할부(2~24개월) 입력/표시 - 투자 소수점 수량 입력(step=any) - 계좌 관리 등록버튼 탭 라인 우측 이동, 정기거래 3단 레이아웃 - 목록 좌측 여백 제거(분류/예산/태그/계좌), 대시보드 일/주 버튼 제거, 도넛/파이 차트 잘림 수정 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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 () => {
|
||||
<div class="panel-head">
|
||||
<h2>기간별 예산 대비 지출</h2>
|
||||
<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 === 'YEAR' }" @click="setUnit('YEAR')">년별</button>
|
||||
</div>
|
||||
@@ -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;
|
||||
|
||||
@@ -133,6 +133,8 @@ button.danger {
|
||||
}
|
||||
.tag-list {
|
||||
list-style: none;
|
||||
padding-left: 0;
|
||||
margin: 0;
|
||||
}
|
||||
.tag-row {
|
||||
display: flex;
|
||||
|
||||
@@ -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 () => {
|
||||
<IconBtn icon="trash" title="삭제" variant="danger" size="sm" @click="remove(e)" />
|
||||
</span>
|
||||
</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-for="t in e.tags" :key="t" class="row-tag">{{ t }}</span>
|
||||
</div>
|
||||
@@ -463,6 +508,26 @@ onMounted(async () => {
|
||||
<h2>{{ editId ? '내역 수정' : '내역 추가' }}</h2>
|
||||
|
||||
<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>구분
|
||||
<select v-model="form.type" :disabled="submitting">
|
||||
@@ -487,6 +552,14 @@ onMounted(async () => {
|
||||
</option>
|
||||
</select>
|
||||
</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'">
|
||||
<label>입금 종류
|
||||
<select v-model="form.toWalletKind" :disabled="submitting" @change="onToWalletKindChange">
|
||||
@@ -829,6 +902,40 @@ button.primary {
|
||||
flex-direction: column;
|
||||
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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -897,6 +1004,19 @@ button.primary {
|
||||
font-size: 0.75rem;
|
||||
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 {
|
||||
margin-right: 0.35rem;
|
||||
font-size: 0.75rem;
|
||||
|
||||
@@ -265,18 +265,17 @@ onBeforeUnmount(() => sortable?.destroy())
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tabs">
|
||||
<button
|
||||
v-for="t in TABS"
|
||||
:key="t.key"
|
||||
type="button"
|
||||
:class="{ active: activeType === t.key }"
|
||||
@click="activeType = t.key"
|
||||
>{{ t.label }}</button>
|
||||
</div>
|
||||
|
||||
<div class="toolbar">
|
||||
<IconBtn icon="plus" title="추가" variant="primary" @click="openCreate" />
|
||||
<div class="tabbar">
|
||||
<div class="tabs">
|
||||
<button
|
||||
v-for="t in TABS"
|
||||
:key="t.key"
|
||||
type="button"
|
||||
:class="{ active: activeType === t.key }"
|
||||
@click="activeType = t.key"
|
||||
>{{ t.label }}</button>
|
||||
</div>
|
||||
<IconBtn class="tab-add" icon="plus" title="추가" variant="primary" @click="openCreate" />
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="msg error">{{ error }}</p>
|
||||
@@ -434,11 +433,23 @@ button.primary {
|
||||
.nw-card .value.debt {
|
||||
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 {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
margin-bottom: 1rem;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.tab-add {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.tabs button {
|
||||
border: 0;
|
||||
@@ -452,11 +463,10 @@ button.primary {
|
||||
color: hsla(160, 100%, 37%, 1);
|
||||
font-weight: 600;
|
||||
}
|
||||
.toolbar {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.wallet-list {
|
||||
list-style: none;
|
||||
padding-left: 0;
|
||||
margin: 0;
|
||||
}
|
||||
.wallet-item {
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
|
||||
@@ -468,6 +468,8 @@ button.primary {
|
||||
}
|
||||
.status-list {
|
||||
list-style: none;
|
||||
padding-left: 0;
|
||||
margin: 0;
|
||||
}
|
||||
.status-row {
|
||||
padding: 0.85rem 0;
|
||||
|
||||
@@ -221,6 +221,8 @@ button.danger {
|
||||
}
|
||||
.cat-list {
|
||||
list-style: none;
|
||||
padding-left: 0;
|
||||
margin: 0;
|
||||
}
|
||||
.cat-row {
|
||||
display: flex;
|
||||
|
||||
@@ -30,6 +30,10 @@ const tradesByHolding = reactive({})
|
||||
function won(n) {
|
||||
return (n ?? 0).toLocaleString('ko-KR')
|
||||
}
|
||||
// 수량(소수점 매매 지원): 최대 6자리, 불필요한 0 제거
|
||||
function shares(n) {
|
||||
return Number(n ?? 0).toLocaleString('ko-KR', { maximumFractionDigits: 6 })
|
||||
}
|
||||
function todayStr() {
|
||||
const d = new Date()
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||
@@ -186,7 +190,7 @@ onMounted(load)
|
||||
<div>
|
||||
<div class="h-name">{{ h.name }}<span v-if="h.ticker" class="h-ticker">{{ h.ticker }}</span></div>
|
||||
<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>
|
||||
<span v-else class="noprice"> · 현재가 미입력</span>
|
||||
</div>
|
||||
@@ -213,7 +217,7 @@ onMounted(load)
|
||||
<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-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-amt">{{ won(t.amount) }}</span>
|
||||
<IconBtn class="t-del" icon="trash" title="매매 삭제" variant="danger" size="sm" @click="removeTrade(t, h.id)" />
|
||||
@@ -261,10 +265,10 @@ onMounted(load)
|
||||
</select>
|
||||
</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.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>
|
||||
<div class="pf-buttons">
|
||||
<IconBtn icon="close" title="취소" @click="tradeModal = false" />
|
||||
|
||||
@@ -191,11 +191,10 @@ onMounted(load)
|
||||
<span class="type-badge" :class="r.type.toLowerCase()">{{ typeLabel(r.type) }}</span>
|
||||
<span v-if="!r.active" class="off">중지</span>
|
||||
</div>
|
||||
<span class="sub">
|
||||
{{ freqLabel(r) }} · {{ won(r.amount) }}
|
||||
<template v-if="r.category"> · {{ r.category }}</template>
|
||||
<template v-if="r.nextDate"> · 다음 {{ r.nextDate }}</template>
|
||||
</span>
|
||||
<div class="sub">
|
||||
{{ freqLabel(r) }} · {{ won(r.amount) }}<template v-if="r.category"> · {{ r.category }}</template>
|
||||
</div>
|
||||
<div v-if="r.nextDate" class="next">다음 {{ r.nextDate }}</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<IconBtn icon="edit" title="수정" size="sm" @click="openEdit(r)" />
|
||||
@@ -328,14 +327,21 @@ button.primary {
|
||||
}
|
||||
.recur-list {
|
||||
list-style: none;
|
||||
padding-left: 0;
|
||||
margin: 0;
|
||||
}
|
||||
.recur-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 0.6rem;
|
||||
padding: 0.7rem 0;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
.info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.recur-row.inactive {
|
||||
opacity: 0.55;
|
||||
}
|
||||
@@ -368,10 +374,17 @@ button.primary {
|
||||
.sub {
|
||||
font-size: 0.83rem;
|
||||
opacity: 0.75;
|
||||
margin-top: 0.15rem;
|
||||
}
|
||||
.next {
|
||||
font-size: 0.78rem;
|
||||
opacity: 0.6;
|
||||
margin-top: 0.1rem;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.actions button {
|
||||
padding: 0.2rem 0.55rem;
|
||||
|
||||
Reference in New Issue
Block a user