Files
sb-front/src/views/account/InvestPortfolio.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

593 lines
16 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 { onMounted, reactive, ref } from 'vue'
import { accountApi } from '@/api/accountApi'
import IconBtn from '@/components/ui/IconBtn.vue'
const props = defineProps({ walletId: { type: [Number, String], required: true } })
const emit = defineEmits(['changed'])
const holdings = ref([])
const loading = ref(false)
// 종목 추가/수정 모달
const holdingModal = ref(false)
const holdingEditId = ref(null)
const hForm = reactive({ name: '', ticker: '', currentPrice: null })
const hSubmitting = ref(false)
const hError = ref(null)
// 매매 모달
const tradeModal = ref(false)
const tradeHolding = ref(null)
const tForm = reactive({ tradeType: 'BUY', tradeDate: '', quantity: null, price: null, fee: null })
const tSubmitting = ref(false)
const tError = ref(null)
// 매매이력 드롭다운
const openTradesId = ref(null)
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')}`
}
function tDate(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')}`
}
async function load() {
loading.value = true
try {
holdings.value = await accountApi.investHoldings(props.walletId)
} catch {
holdings.value = []
} finally {
loading.value = false
}
}
/* ===== 종목 ===== */
function openAddHolding() {
holdingEditId.value = null
Object.assign(hForm, { name: '', ticker: '', currentPrice: null })
hError.value = null
holdingModal.value = true
}
function openEditHolding(h) {
holdingEditId.value = h.id
Object.assign(hForm, { name: h.name, ticker: h.ticker || '', currentPrice: h.currentPrice ?? null })
hError.value = null
holdingModal.value = true
}
async function submitHolding() {
hError.value = null
if (!hForm.name.trim()) {
hError.value = '종목명을 입력하세요.'
return
}
hSubmitting.value = true
const payload = {
walletId: Number(props.walletId),
name: hForm.name.trim(),
ticker: hForm.ticker || null,
currentPrice: hForm.currentPrice !== '' && hForm.currentPrice != null ? Number(hForm.currentPrice) : null,
}
try {
if (holdingEditId.value) await accountApi.updateHolding(holdingEditId.value, payload)
else await accountApi.createHolding(payload)
holdingModal.value = false
await load()
emit('changed')
} catch (e) {
hError.value = e.response?.data?.message || '저장에 실패했습니다.'
} finally {
hSubmitting.value = false
}
}
async function removeHolding(h) {
if (!confirm(`'${h.name}' 종목과 매매이력을 모두 삭제할까요?`)) return
try {
await accountApi.removeHolding(h.id)
await load()
emit('changed')
} catch (e) {
alert(e.response?.data?.message || '삭제에 실패했습니다.')
}
}
/* ===== 매매 ===== */
function openTrade(h, type) {
tradeHolding.value = h
Object.assign(tForm, { tradeType: type, tradeDate: todayStr(), quantity: null, price: h.currentPrice ?? null, fee: null })
tError.value = null
tradeModal.value = true
}
async function submitTrade() {
tError.value = null
if (!tForm.quantity || tForm.quantity <= 0) {
tError.value = '수량을 입력하세요.'
return
}
if (tForm.price == null || tForm.price < 0) {
tError.value = '단가를 입력하세요.'
return
}
tSubmitting.value = true
const payload = {
tradeType: tForm.tradeType,
tradeDate: tForm.tradeDate,
quantity: Number(tForm.quantity),
price: Number(tForm.price),
fee: tForm.fee ? Number(tForm.fee) : 0,
}
try {
await accountApi.addTrade(tradeHolding.value.id, payload)
tradeModal.value = false
if (openTradesId.value === tradeHolding.value.id) await loadTrades(tradeHolding.value.id)
await load()
emit('changed')
} catch (e) {
tError.value = e.response?.data?.message || '저장에 실패했습니다.'
} finally {
tSubmitting.value = false
}
}
async function loadTrades(holdingId) {
try {
tradesByHolding[holdingId] = await accountApi.holdingTrades(holdingId)
} catch {
tradesByHolding[holdingId] = []
}
}
async function toggleTrades(h) {
if (openTradesId.value === h.id) {
openTradesId.value = null
return
}
openTradesId.value = h.id
await loadTrades(h.id)
}
async function removeTrade(t, holdingId) {
if (!confirm('이 매매 내역을 삭제할까요?')) return
try {
await accountApi.removeTrade(t.id)
await loadTrades(holdingId)
await load()
emit('changed')
} catch (e) {
alert(e.response?.data?.message || '삭제에 실패했습니다.')
}
}
onMounted(load)
</script>
<template>
<div class="portfolio">
<div class="pf-head">
<span class="pf-title">보유 종목</span>
<IconBtn icon="plus" title="종목 추가" variant="primary" size="sm" @click="openAddHolding" />
</div>
<p v-if="loading" class="pf-msg">불러오는 중...</p>
<p v-else-if="!holdings.length" class="pf-msg">보유 종목이 없습니다. 종목을 추가하고 매수를 기록하세요.</p>
<ul v-else class="hold-list">
<li v-for="h in holdings" :key="h.id" class="hold-item">
<div class="hold-row">
<div class="h-info" @click="toggleTrades(h)">
<span class="chev">{{ openTradesId === h.id ? '▾' : '▸' }}</span>
<div>
<div class="h-name">{{ h.name }}<span v-if="h.ticker" class="h-ticker">{{ h.ticker }}</span></div>
<div class="h-sub">
{{ shares(h.quantity) }} · 평단 {{ won(h.avgPrice) }}
<template v-if="h.currentPrice != null"> · 현재가 {{ won(h.currentPrice) }}</template>
<span v-else class="noprice"> · 현재가 미입력</span>
</div>
</div>
</div>
<div class="h-val">
<div class="h-eval">{{ won(h.evalValue) }}</div>
<div class="h-gain" :class="h.evalGain < 0 ? 'neg' : 'pos'">
{{ h.evalGain >= 0 ? '+' : '' }}{{ won(h.evalGain) }}<span v-if="h.returnPct != null"> ({{ h.returnPct }}%)</span>
</div>
<div v-if="h.realizedPL" class="h-realized">실현 {{ h.realizedPL >= 0 ? '+' : '' }}{{ won(h.realizedPL) }}</div>
</div>
<div class="h-actions">
<IconBtn icon="plus" title="매수" variant="buy" size="sm" @click="openTrade(h, 'BUY')" />
<IconBtn icon="minus" title="매도" variant="sell" size="sm" :disabled="h.quantity <= 0" @click="openTrade(h, 'SELL')" />
<IconBtn icon="edit" title="현재가 수정" size="sm" @click="openEditHolding(h)" />
<IconBtn icon="trash" title="삭제" variant="danger" size="sm" @click="removeHolding(h)" />
</div>
</div>
<!-- 매매이력 -->
<div v-if="openTradesId === h.id" class="trade-drop">
<ul v-if="(tradesByHolding[h.id] || []).length" class="trade-list">
<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">{{ 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)" />
</li>
</ul>
<p v-else class="pf-msg sm">매매 내역이 없습니다.</p>
</div>
</li>
</ul>
<!-- 종목 추가/수정 모달 -->
<Teleport to="body">
<Transition name="fade">
<div v-if="holdingModal" class="modal-backdrop" @click.self="holdingModal = false">
<div class="modal" role="dialog" aria-modal="true">
<button class="close" type="button" @click="holdingModal = false">×</button>
<h2>종목 {{ holdingEditId ? '수정' : '추가' }}</h2>
<form class="pf-form" @submit.prevent="submitHolding">
<label>종목명<input v-model="hForm.name" type="text" placeholder="예: 삼성전자" :disabled="hSubmitting" /></label>
<label>종목코드<input v-model="hForm.ticker" type="text" placeholder="(선택) 예: 005930" :disabled="hSubmitting" /></label>
<label>현재가<input v-model.number="hForm.currentPrice" type="number" min="0" placeholder="(수동 갱신)" :disabled="hSubmitting" /></label>
<p v-if="hError" class="pf-msg err">{{ hError }}</p>
<div class="pf-buttons">
<IconBtn icon="close" title="취소" @click="holdingModal = false" />
<IconBtn icon="save" title="저장" variant="primary" type="submit" :disabled="hSubmitting" />
</div>
</form>
</div>
</div>
</Transition>
</Teleport>
<!-- 매매 모달 -->
<Teleport to="body">
<Transition name="fade">
<div v-if="tradeModal" class="modal-backdrop" @click.self="tradeModal = false">
<div class="modal" role="dialog" aria-modal="true">
<button class="close" type="button" @click="tradeModal = false">×</button>
<h2>{{ tradeHolding?.name }} {{ tForm.tradeType === 'SELL' ? '매도' : '매수' }}</h2>
<form class="pf-form" @submit.prevent="submitTrade">
<label>구분
<select v-model="tForm.tradeType" :disabled="tSubmitting">
<option value="BUY">매수</option>
<option value="SELL" :disabled="(tradeHolding?.quantity || 0) <= 0">매도</option>
</select>
</label>
<label>거래일<input v-model="tForm.tradeDate" type="date" :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">보유 {{ 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" />
<IconBtn icon="save" title="등록" variant="primary" type="submit" :disabled="tSubmitting" />
</div>
</form>
</div>
</div>
</Transition>
</Teleport>
</div>
</template>
<style scoped>
.portfolio {
padding: 0.25rem 0 0.5rem;
}
.pf-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.5rem;
}
.pf-title {
font-size: 0.85rem;
font-weight: 600;
opacity: 0.8;
}
.pf-add {
padding: 0.25rem 0.6rem;
font-size: 0.8rem;
border: 1px solid var(--color-border);
border-radius: 4px;
background: var(--color-background-soft);
color: var(--color-text);
cursor: pointer;
}
.pf-msg {
font-size: 0.85rem;
opacity: 0.65;
padding: 0.5rem 0;
}
.pf-msg.sm {
padding: 0.3rem 0;
}
.pf-msg.err {
color: #c0392b;
opacity: 1;
}
.hold-list {
list-style: none;
}
.hold-item {
border-top: 1px dashed var(--color-border);
}
.hold-row {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0;
flex-wrap: wrap;
}
.h-info {
display: flex;
align-items: center;
gap: 0.4rem;
flex: 1;
min-width: 9rem;
cursor: pointer;
}
.chev {
opacity: 0.5;
font-size: 0.75rem;
}
.h-name {
font-weight: 600;
font-size: 0.9rem;
}
.h-ticker {
margin-left: 0.35rem;
font-size: 0.72rem;
opacity: 0.6;
}
.h-sub {
font-size: 0.76rem;
opacity: 0.7;
}
.noprice {
color: #e67e22;
}
.h-val {
text-align: right;
white-space: nowrap;
}
.h-eval {
font-weight: 700;
font-size: 0.9rem;
}
.h-gain {
font-size: 0.76rem;
font-weight: 600;
}
.h-gain.pos {
color: #2e7d32;
}
.h-gain.neg {
color: #c0392b;
}
.h-realized {
font-size: 0.7rem;
opacity: 0.7;
}
.h-actions {
display: flex;
gap: 0.25rem;
}
.h-actions button {
padding: 0.18rem 0.45rem;
font-size: 0.76rem;
border: 1px solid var(--color-border);
border-radius: 4px;
background: var(--color-background-soft);
color: var(--color-text);
cursor: pointer;
}
.h-actions button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.h-actions .buy {
border-color: #2e7d32;
color: #2e7d32;
}
.h-actions .sell {
border-color: #c0392b;
color: #c0392b;
}
.h-actions .danger {
border-color: #c0392b;
color: #c0392b;
}
.trade-drop {
padding: 0.1rem 0 0.5rem 1.4rem;
}
.trade-list {
list-style: none;
}
.trade-row {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.25rem 0;
font-size: 0.78rem;
}
.t-date {
opacity: 0.7;
width: 3rem;
}
.t-type {
font-weight: 600;
}
.t-type.buy {
color: #2e7d32;
}
.t-type.sell {
color: #c0392b;
}
.t-qty {
flex: 1;
}
.t-fee {
opacity: 0.6;
font-size: 0.72rem;
}
.t-amt {
font-weight: 600;
}
.t-del {
border: 0;
background: transparent;
color: #c0392b;
cursor: pointer;
font-size: 1rem;
line-height: 1;
}
/* 모달 (계좌 관리와 동일 톤) */
.modal-backdrop {
position: fixed;
inset: 0;
z-index: 1100;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.5);
padding: 1rem;
}
.modal {
position: relative;
width: 100%;
max-width: 340px;
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;
cursor: pointer;
}
.modal h2 {
margin-bottom: 1rem;
font-size: 1.1rem;
text-align: center;
}
.pf-form {
display: flex;
flex-direction: column;
gap: 0.55rem;
}
.pf-form label {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: 0.85rem;
}
.pf-form input,
.pf-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);
}
.pf-hint {
font-size: 0.78rem;
opacity: 0.7;
}
.pf-buttons {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
margin-top: 0.5rem;
}
.pf-buttons 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;
}
.pf-buttons .primary {
border-color: hsla(160, 100%, 37%, 1);
color: hsla(160, 100%, 37%, 1);
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.18s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
/* ===== 모바일: 매매이력 2줄, 글자 세로쪼개짐 방지 ===== */
@media (max-width: 768px) {
.hold-row {
column-gap: 0.4rem;
}
.h-actions {
flex-wrap: wrap;
justify-content: flex-end;
}
.trade-drop {
padding-left: 0.8rem;
}
.trade-row {
flex-wrap: wrap;
column-gap: 0.4rem;
row-gap: 0.1rem;
}
.t-date,
.t-type,
.t-qty,
.t-fee,
.t-amt {
white-space: nowrap;
}
.t-date {
order: 0;
width: auto;
}
.t-type {
order: 1;
}
.t-amt {
order: 2;
margin-left: auto;
}
.t-del {
order: 3;
}
.t-qty {
order: 4;
flex: 0 1 auto;
}
.t-fee {
order: 5;
}
}
</style>