Files
sb-front/src/views/account/InvestPortfolio.vue
T
ByungCheol 13308a82ef feat: 가계부·게시판 프론트엔드 구현 + Slim Budget 브랜딩/모바일 대응
- 가계부: 대시보드(예산 대비 지출·분류별 파이·기간별 막대·순자산 추이),
  내역(검색·필터·태그), 계좌(은행/카드/대출/투자), 예산, 분류, 정기 거래,
  태그, 투자 포트폴리오(종목·매수/매도·평가손익)
- 게시판: 목록/상세/작성(마크다운)/댓글/태그/열람제한
- 공통: 인증(Bearer 토큰·401 처리), 모바일 반응형(드로어·아이콘 버튼),
  Slim Budget 브랜딩(로고/타이틀/푸터), 홈 대시보드 자리
- 문서: docs/FRONTEND.md

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 15:42:52 +09:00

589 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')
}
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">
{{ won(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">{{ won(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="1" :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="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>