Files
sb-front/src/views/account/InvestPortfolio.vue
T
ByungCheol 41d6f11531
CI / build (push) Failing after 15m19s
fix: 투자 소수점 매수 금액 입력 모드 추가
- 매매 모달에 '입력 방식: 단가/금액' 선택 추가
- 금액 모드: 수량+총금액 입력 → 단가=round(금액/수량) 자동계산 (소수점 매수)
- 예) 0.6주를 10,000원에 매수 시 6,000원이 아니라 10,000원으로 정확히 기록
- 단가/금액 상호 미리보기 표시

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 17:44:01 +09:00

626 lines
18 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 { computed, 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)
// inputMode: 'PRICE'(수량×단가) | 'AMOUNT'(수량+총금액 → 단가 자동계산, 소수점 매수용)
const tForm = reactive({ tradeType: 'BUY', tradeDate: '', quantity: null, price: null, amount: null, fee: null, inputMode: 'PRICE' })
const tSubmitting = ref(false)
const tError = ref(null)
const tradeQty = computed(() => Number(tForm.quantity) || 0)
// 실제 저장에 쓰는 단가(원/주). 금액 모드면 총금액÷수량을 반올림.
const tradePrice = computed(() => {
if (tForm.inputMode === 'AMOUNT') {
return tradeQty.value > 0 ? Math.round((Number(tForm.amount) || 0) / tradeQty.value) : 0
}
return Number(tForm.price) || 0
})
// 표시용 총 거래금액
const tradeAmount = computed(() => {
if (tForm.inputMode === 'AMOUNT') return Number(tForm.amount) || 0
return Math.round(tradeQty.value * (Number(tForm.price) || 0))
})
// 매매이력 드롭다운
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, amount: null, fee: null, inputMode: 'PRICE' })
tError.value = null
tradeModal.value = true
}
async function submitTrade() {
tError.value = null
if (!tForm.quantity || tForm.quantity <= 0) {
tError.value = '수량을 입력하세요.'
return
}
if (tForm.inputMode === 'AMOUNT') {
if (!tForm.amount || tForm.amount <= 0) {
tError.value = '금액을 입력하세요.'
return
}
} else 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: tradePrice.value,
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>입력 방식
<select v-model="tForm.inputMode" :disabled="tSubmitting">
<option value="PRICE">단가로 입력</option>
<option value="AMOUNT">금액으로 입력 (소수점 매수)</option>
</select>
</label>
<label v-if="tForm.inputMode === 'PRICE'">단가()
<input v-model.number="tForm.price" type="number" min="0" :disabled="tSubmitting" />
<small v-if="tradeQty > 0 && tForm.price" class="pf-hint"> {{ won(tradeAmount) }}</small>
</label>
<label v-else>금액(, )
<input v-model.number="tForm.amount" type="number" min="0" placeholder="이 금액어치 매매" :disabled="tSubmitting" />
<small v-if="tradeQty > 0 && tForm.amount" class="pf-hint">단가 {{ won(tradePrice) }}</small>
</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>