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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 16:42:07 +09:00

725 lines
19 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup>
import { onBeforeUnmount, onMounted, reactive, ref, watch, nextTick } from 'vue'
import { useRouter } from 'vue-router'
import Sortable from 'sortablejs'
import { accountApi } from '@/api/accountApi'
import InvestPortfolio from './InvestPortfolio.vue'
import IconBtn from '@/components/ui/IconBtn.vue'
const router = useRouter()
const TABS = [
{ key: 'BANK', label: '은행계좌' },
{ key: 'CARD', label: '신용/체크카드' },
{ key: 'LOAN', label: '대출' },
{ key: 'INVEST', label: '투자' },
]
const wallets = ref([])
const networth = ref({ totalAssets: 0, totalLiabilities: 0, netWorth: 0 })
const loading = ref(false)
const error = ref(null)
const activeType = ref('BANK')
const formOpen = ref(false)
const editId = ref(null)
// openingBalance 는 화면 입력값(부채는 갚을 금액의 절대값). 서버 저장 시 부호 변환.
const form = reactive({
type: 'BANK',
name: '',
issuer: '',
accountNumber: '',
cardType: 'CREDIT',
openingBalance: 0,
openingDate: '',
currentValue: null,
})
const submitting = ref(false)
const formError = ref(null)
// 드롭다운(계좌별 내역)
const expandedId = ref(null)
const entriesByWallet = reactive({})
const loadingEntries = ref(false)
const isLiability = (t) => t === 'CARD' || t === 'LOAN'
// 현재 탭(activeType)의 계좌 — 드래그 정렬 대상 (백엔드가 sort_order 순 정렬)
const rows = ref([])
function syncRows() {
rows.value = wallets.value.filter((w) => w.type === activeType.value)
}
watch([wallets, activeType], syncRows, { immediate: true })
// ===== 드래그앤드랍 순서 변경 (SortableJS, 터치 지원) =====
const listEl = ref(null)
let sortable = null
function initSortable() {
if (sortable) {
sortable.destroy()
sortable = null
}
if (!listEl.value) return
sortable = Sortable.create(listEl.value, {
handle: '.drag-handle',
animation: 150,
onEnd: (evt) => {
if (evt.oldIndex === evt.newIndex) return
const arr = rows.value
const moved = arr.splice(evt.oldIndex, 1)[0]
arr.splice(evt.newIndex, 0, moved)
persistOrder(arr.map((r) => r.id))
},
})
}
async function persistOrder(ids) {
try {
await accountApi.reorderWallets(activeType.value, ids)
await load()
} catch (e) {
alert(e.response?.data?.message || '순서 저장에 실패했습니다.')
await load()
}
}
async function toggleExpand(w) {
if (expandedId.value === w.id) {
expandedId.value = null
return
}
expandedId.value = w.id
if (w.type === 'INVEST') return // 포트폴리오 컴포넌트가 자체 로드
loadingEntries.value = true
try {
entriesByWallet[w.id] = await accountApi.walletEntries(w.id)
} catch {
entriesByWallet[w.id] = []
} finally {
loadingEntries.value = false
}
}
// 이 계좌 기준 증감(+입금/상환, -지출/출금)
function effectAmount(e, walletId) {
if (e.type === 'TRANSFER') return e.toWalletId === walletId ? e.amount : -e.amount
return e.type === 'INCOME' ? e.amount : -e.amount
}
function entryDesc(e, w) {
const m = e.memo ? ` · ${e.memo}` : ''
if (e.type === 'TRANSFER') {
if (e.toWalletId === w.id) {
// 이 계좌로 들어온 이체: 부채는 상환/납부, 그 외(은행·투자)는 입금
const inLabel = w.type === 'CARD' || w.type === 'LOAN' ? '상환/납부' : '입금'
return `${inLabel}${e.walletName || '계좌'}${m}`
}
return `이체 → ${e.toWalletName || '계좌'}${m}`
}
if (e.type === 'INCOME') return `수입${e.category ? ' · ' + e.category : ''}${m}`
return `${e.category || '지출'}${m}`
}
function entryDate(v) {
if (!v) return '-'
const d = new Date(v)
return Number.isNaN(d.getTime())
? v
: `${String(d.getMonth() + 1).padStart(2, '0')}.${String(d.getDate()).padStart(2, '0')}`
}
function won(n) {
return (n ?? 0).toLocaleString('ko-KR')
}
function issuerLabel(t) {
return t === 'BANK' ? '은행명' : t === 'CARD' ? '카드사' : t === 'INVEST' ? '증권사' : '대출기관'
}
function openingLabel(t) {
return t === 'BANK' ? '초기 잔액' : t === 'CARD' ? '초기 미결제 금액' : t === 'INVEST' ? '초기 투입원금' : '대출 잔액(원금)'
}
// 투자 수익률(%) — 투입원금 대비 평가손익
function returnPct(w) {
if (w.type !== 'INVEST' || !w.investedAmount || w.valuationGain == null) return null
return Math.round((w.valuationGain / w.investedAmount) * 1000) / 10
}
async function load() {
loading.value = true
error.value = null
try {
const [ws, nw] = await Promise.all([accountApi.wallets(), accountApi.netWorth()])
wallets.value = ws
networth.value = nw
} catch (e) {
error.value = e.response?.data?.message || '불러오지 못했습니다.'
} finally {
loading.value = false
}
}
function openCreate() {
editId.value = null
Object.assign(form, {
type: activeType.value,
name: '',
issuer: '',
accountNumber: '',
cardType: 'CREDIT',
openingBalance: 0,
openingDate: '',
currentValue: null,
})
formError.value = null
formOpen.value = true
}
function openEdit(w) {
editId.value = w.id
Object.assign(form, {
type: w.type,
name: w.name,
issuer: w.issuer || '',
accountNumber: w.accountNumber || '',
cardType: w.cardType || 'CREDIT',
// 부채는 음수 저장값 → 화면엔 절대값(갚을 금액)으로
openingBalance: isLiability(w.type) ? -(w.openingBalance || 0) : w.openingBalance || 0,
openingDate: w.openingDate || '',
currentValue: w.currentValue ?? null,
})
formError.value = null
formOpen.value = true
}
async function submit() {
formError.value = null
if (!form.name.trim()) {
formError.value = '이름을 입력하세요.'
return
}
submitting.value = true
const magnitude = Number(form.openingBalance) || 0
const payload = {
type: form.type,
name: form.name.trim(),
issuer: form.issuer || null,
accountNumber: form.type === 'BANK' ? form.accountNumber || null : null,
cardType: form.type === 'CARD' ? form.cardType : null,
// 부채는 음수로 저장 (갚을 금액)
openingBalance: isLiability(form.type) ? -magnitude : magnitude,
openingDate: form.openingDate || null,
currentValue:
form.type === 'INVEST' && form.currentValue !== '' && form.currentValue != null
? Number(form.currentValue)
: null,
}
try {
if (editId.value) await accountApi.updateWallet(editId.value, payload)
else await accountApi.createWallet(payload)
formOpen.value = false
await load()
} catch (e) {
formError.value = e.response?.data?.message || '저장에 실패했습니다.'
} finally {
submitting.value = false
}
}
async function remove(w) {
if (!confirm(`'${w.name}' 을(를) 삭제할까요?`)) return
try {
await accountApi.removeWallet(w.id)
await load()
} catch (e) {
alert(e.response?.data?.message || '삭제에 실패했습니다.')
}
}
function cardTypeLabel(v) {
return v === 'CHECK' ? '체크' : v === 'CREDIT' ? '신용' : ''
}
onMounted(async () => {
await load()
await nextTick()
initSortable()
})
onBeforeUnmount(() => sortable?.destroy())
</script>
<template>
<section class="wallet">
<header class="head">
<h1>계좌 관리</h1>
<IconBtn icon="back" title="가계부로" @click="router.push('/account')" />
</header>
<!-- 순자산 요약 -->
<div class="networth">
<div class="nw-card">
<span class="label">총자산</span>
<span class="value asset">{{ won(networth.totalAssets) }}</span>
</div>
<div class="nw-card">
<span class="label">총부채</span>
<span class="value debt">{{ won(networth.totalLiabilities) }}</span>
</div>
<div class="nw-card">
<span class="label">순자산</span>
<span class="value">{{ won(networth.netWorth) }}</span>
</div>
</div>
<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>
<p v-if="loading" class="msg">불러오는 중...</p>
<ul v-show="!loading && rows.length" ref="listEl" class="wallet-list">
<li v-for="w in rows" :key="w.id" :data-id="w.id" class="wallet-item">
<div class="wallet-row" @click="toggleExpand(w)">
<span class="drag-handle" title="드래그하여 순서 변경" @click.stop></span>
<span class="chevron">{{ expandedId === w.id ? '▾' : '▸' }}</span>
<div class="info">
<div class="line1">
<span class="name">{{ w.name }}</span>
<span v-if="w.type === 'CARD'" class="badge">{{ cardTypeLabel(w.cardType) }}</span>
</div>
<span class="sub">
{{ w.issuer }}
<template v-if="w.type === 'BANK' && w.accountNumber"> · {{ w.accountNumber }}</template>
<template v-if="w.type === 'INVEST'"> · 예수금 {{ won(w.deposit) }} · 주식 {{ won(w.stockValue) }}</template>
</span>
</div>
<div class="balance-wrap">
<div class="balance" :class="{ neg: w.balance < 0 }">{{ won(w.balance) }}</div>
<div
v-if="w.type === 'INVEST' && w.valuationGain != null"
class="gain" :class="w.valuationGain < 0 ? 'neg' : 'pos'"
>
{{ w.valuationGain >= 0 ? '+' : '' }}{{ won(w.valuationGain) }}<span v-if="returnPct(w) != null"> ({{ returnPct(w) }}%)</span>
</div>
</div>
<div class="actions" @click.stop>
<IconBtn icon="edit" title="수정" size="sm" @click="openEdit(w)" />
<IconBtn icon="trash" title="삭제" variant="danger" size="sm" @click="remove(w)" />
</div>
</div>
<!-- 드롭다운: 투자는 포트폴리오, 외는 계좌별 내역 -->
<div v-if="expandedId === w.id" class="entry-drop">
<InvestPortfolio v-if="w.type === 'INVEST'" :wallet-id="w.id" @changed="load" />
<template v-else>
<p v-if="loadingEntries" class="drop-msg">불러오는 중...</p>
<ul v-else-if="(entriesByWallet[w.id] || []).length" class="drop-list">
<li v-for="e in entriesByWallet[w.id]" :key="e.id" class="drop-row">
<span class="d-date">{{ entryDate(e.entryDate) }}</span>
<span class="d-desc">{{ entryDesc(e, w) }}</span>
<span class="d-amount" :class="effectAmount(e, w.id) < 0 ? 'neg' : 'pos'">
{{ effectAmount(e, w.id) >= 0 ? '+' : '' }}{{ won(effectAmount(e, w.id)) }}
</span>
</li>
</ul>
<p v-else class="drop-msg">연관 내역이 없습니다.</p>
</template>
</div>
</li>
</ul>
<p v-if="!loading && !rows.length" class="msg">등록된 항목이 없습니다.</p>
<!-- 추가/수정 모달 -->
<Teleport to="body">
<Transition name="fade">
<div v-if="formOpen" class="modal-backdrop" @click.self="formOpen = false">
<div class="modal" role="dialog" aria-modal="true">
<button class="close" type="button" @click="formOpen = false">×</button>
<h2>{{ TABS.find((t) => t.key === form.type)?.label }} {{ editId ? '수정' : '추가' }}</h2>
<form class="wallet-form" @submit.prevent="submit">
<label>이름(별칭)<input v-model="form.name" type="text" placeholder="예: 월급통장 / 메인카드 / 신용대출" :disabled="submitting" /></label>
<label>{{ issuerLabel(form.type) }}<input v-model="form.issuer" type="text" :disabled="submitting" /></label>
<label v-if="form.type === 'BANK'">계좌번호<input v-model="form.accountNumber" type="text" placeholder="(선택)" :disabled="submitting" /></label>
<label v-if="form.type === 'CARD'">카드 종류
<select v-model="form.cardType" :disabled="submitting">
<option value="CREDIT">신용카드</option>
<option value="CHECK">체크카드</option>
</select>
</label>
<label>{{ openingLabel(form.type) }}<input v-model.number="form.openingBalance" type="number" min="0" :disabled="submitting" /></label>
<p v-if="form.type === 'INVEST'" class="form-hint">
증권계좌 입금은 은행투자 이체 기록하세요. 계좌를 펼치면 <b>종목·매수/매도·현재가</b> 관리할 있고, 평가금액·손익은 자동 계산됩니다.
</p>
<label>기준일<input v-model="form.openingDate" type="date" :disabled="submitting" /></label>
<p v-if="formError" class="msg error">{{ formError }}</p>
<div class="buttons">
<IconBtn icon="close" title="취소" @click="formOpen = false" />
<IconBtn icon="save" :title="editId ? '수정' : '등록'" variant="primary" type="submit" :disabled="submitting" />
</div>
</form>
</div>
</div>
</Transition>
</Teleport>
</section>
</template>
<style scoped>
.wallet {
max-width: 640px;
margin: 0 auto;
}
.head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
}
h1 {
font-size: 1.5rem;
}
button {
padding: 0.45rem 0.9rem;
border: 1px solid var(--color-border);
border-radius: 4px;
background: var(--color-background-soft);
color: var(--color-text);
cursor: pointer;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
button.danger {
border-color: #c0392b;
color: #c0392b;
}
button.primary {
border-color: hsla(160, 100%, 37%, 1);
color: hsla(160, 100%, 37%, 1);
}
.networth {
display: flex;
gap: 0.75rem;
margin-bottom: 1.25rem;
}
.nw-card {
flex: 1;
border: 1px solid var(--color-border);
border-radius: 6px;
padding: 0.7rem 1rem;
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.nw-card .label {
font-size: 0.82rem;
opacity: 0.7;
}
.nw-card .value {
font-size: 1.1rem;
font-weight: 700;
}
.nw-card .value.asset {
color: #2e7d32;
}
.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;
flex: 1;
min-width: 0;
overflow-x: auto;
}
.tab-add {
flex-shrink: 0;
}
.tabs button {
border: 0;
border-bottom: 2px solid transparent;
border-radius: 0;
background: transparent;
padding: 0.6rem 1rem;
}
.tabs button.active {
border-bottom-color: hsla(160, 100%, 37%, 1);
color: hsla(160, 100%, 37%, 1);
font-weight: 600;
}
.wallet-list {
list-style: none;
padding-left: 0;
margin: 0;
}
.wallet-item {
border-bottom: 1px solid var(--color-border);
}
.wallet-row {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.7rem 0;
cursor: pointer;
}
.wallet-row:hover {
background: var(--color-background-soft);
}
.drag-handle {
cursor: grab;
user-select: none;
opacity: 0.45;
font-size: 1.1rem;
padding: 0 0.15rem;
touch-action: none;
}
.drag-handle:active {
cursor: grabbing;
}
.chevron {
width: 1rem;
opacity: 0.6;
font-size: 0.8rem;
}
.entry-drop {
padding: 0.25rem 0 0.75rem 1.6rem;
}
.drop-msg {
font-size: 0.85rem;
opacity: 0.65;
padding: 0.5rem 0;
}
.drop-list {
list-style: none;
}
.drop-row {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.35rem 0;
font-size: 0.85rem;
border-top: 1px dashed var(--color-border);
}
.d-date {
width: 3rem;
opacity: 0.7;
white-space: nowrap;
}
.d-desc {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.d-amount {
white-space: nowrap;
font-weight: 600;
}
.d-amount.pos {
color: #2e7d32;
}
.d-amount.neg {
color: #c0392b;
}
.info {
flex: 1;
min-width: 0;
}
.line1 {
display: flex;
align-items: center;
gap: 0.4rem;
}
.name {
font-weight: 600;
}
.badge {
font-size: 0.72rem;
border: 1px solid var(--color-border);
border-radius: 3px;
padding: 0.05rem 0.35rem;
}
.sub {
font-size: 0.82rem;
opacity: 0.7;
}
.balance-wrap {
text-align: right;
white-space: nowrap;
}
.balance {
font-weight: 700;
white-space: nowrap;
}
.balance.neg {
color: #c0392b;
}
.gain {
font-size: 0.76rem;
font-weight: 600;
}
.gain.pos {
color: #2e7d32;
}
.gain.neg {
color: #c0392b;
}
.form-hint {
font-size: 0.76rem;
opacity: 0.65;
margin: -0.2rem 0 0.1rem;
}
.actions {
display: flex;
gap: 0.4rem;
}
.actions button {
padding: 0.2rem 0.55rem;
font-size: 0.82rem;
}
.msg {
margin: 1rem 0;
}
.msg.error {
color: #c0392b;
}
/* 모달 */
.modal-backdrop {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.5);
padding: 1rem;
}
.modal {
position: relative;
width: 100%;
max-width: 360px;
padding: 1.75rem 1.5rem 1.5rem;
background: var(--color-background);
border: 1px solid var(--color-border);
border-radius: 8px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.25);
}
.modal .close {
position: absolute;
top: 0.5rem;
right: 0.6rem;
width: 2rem;
height: 2rem;
border: 0;
background: transparent;
font-size: 1.5rem;
line-height: 1;
}
.modal h2 {
margin-bottom: 1rem;
font-size: 1.2rem;
text-align: center;
}
.wallet-form {
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.wallet-form label {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: 0.85rem;
}
.wallet-form input,
.wallet-form select {
padding: 0.5rem 0.7rem;
border: 1px solid var(--color-border);
border-radius: 4px;
background: var(--color-background-soft);
color: var(--color-text);
}
.buttons {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
margin-top: 0.5rem;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.18s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
@media (max-width: 768px) {
.wallet {
max-width: 100%;
}
.networth {
gap: 0.5rem;
}
.nw-card {
padding: 0.5rem 0.6rem;
}
.nw-card .value {
font-size: 0.95rem;
}
.tabs button {
padding: 0.55rem 0.6rem;
font-size: 0.9rem;
}
/* 한 줄 유지: 이름(축소) · 잔액 · 아이콘 */
.wallet-row {
column-gap: 0.4rem;
}
.info {
flex: 1;
min-width: 0;
}
/* 이름은 한 줄(말줄임), sub(예수금·주식 등)는 자연 줄바꿈 — 손익과 겹치지 않게 */
.line1 {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sub {
word-break: break-all;
}
.wallet-row {
align-items: flex-start;
}
.balance {
font-size: 0.95rem;
}
.balance-wrap {
flex-shrink: 0;
padding-left: 0.3rem;
}
.actions {
flex-shrink: 0;
gap: 0.3rem;
}
}
</style>