Files
sb-front/src/views/account/InvestPortfolio.vue
T
ByungCheol ea9fbc359b
Deploy / deploy (push) Failing after 12m1s
feat(ui): 입력 모달 전체화면 슬라이드업 + 셀렉트→뱃지 전면 전환
- AppModal.vue: 전체화면 슬라이드업 공통 모달 (Teleport+Transition)
- CategoryPicker.vue: 대/소분류 아코디언 칩 공통 컴포넌트
  admin 모드(일반: 행 단위+수정/삭제, 순서변경: flat+SortableJS)
- AccountView: 계좌종류·구분 셀렉트→뱃지, 분류→CategoryPicker
- RecurringView: 구분·주기 셀렉트→뱃지, 계좌/카드→뱃지, 분류→CategoryPicker
- AccountWalletView: 카드종류·상환방식 셀렉트→뱃지
- BudgetView: 비고정/고정 라디오→뱃지, 카테고리→CategoryPicker
- CategoryView: 리스트 UI → CategoryPicker 칩 그리드 관리 모드

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-04 01:24:15 +09:00

721 lines
21 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'
import AppModal from '@/components/ui/AppModal.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 tradeEditId = ref(null) // 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))
})
// 매매이력 드롭다운 — 인라인은 최근 N건, 전체는 모달
const openTradesId = ref(null)
const tradesByHolding = reactive({})
const RECENT_N = 5
const allTradesModal = ref(false)
const allTradesHolding = ref(null)
function tradesOf(h) {
return tradesByHolding[h.id] || []
}
function recentTrades(h) {
return [...tradesOf(h)].reverse().slice(0, RECENT_N) // 최신순 N건
}
const allTradesList = computed(() => {
const id = allTradesHolding.value?.id
return id ? [...(tradesByHolding[id] || [])].reverse() : []
})
function openAllTrades(h) {
allTradesHolding.value = h
allTradesModal.value = true
}
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')}`
}
// 날짜 input(yyyy-mm-dd) 용 변환 (수정 진입 시 프리필)
function toDateInput(v) {
if (!v) return todayStr()
const d = new Date(v)
if (Number.isNaN(d.getTime())) return String(v).slice(0, 10)
return `${d.getFullYear()}-${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
}
}
// 종목코드로 현재가 시세 일괄 갱신
const refreshing = ref(false)
const refreshMsg = ref('')
async function refreshPrices() {
if (refreshing.value) return
refreshing.value = true
refreshMsg.value = ''
try {
holdings.value = await accountApi.refreshPrices(props.walletId)
emit('changed')
const noTicker = holdings.value.filter((h) => !h.ticker).length
refreshMsg.value = noTicker
? `시세 갱신 완료 · 종목코드 없는 ${noTicker}개는 제외`
: '시세 갱신 완료'
} catch {
refreshMsg.value = '시세 갱신에 실패했습니다.'
} finally {
refreshing.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
tradeEditId.value = null
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
}
// 기존 매매 수정 진입 — 단가 모드로 프리필
function openEditTrade(h, t) {
tradeHolding.value = h
tradeEditId.value = t.id
Object.assign(tForm, {
tradeType: t.tradeType,
tradeDate: toDateInput(t.tradeDate),
quantity: Number(t.quantity),
price: t.price,
amount: null,
fee: t.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 {
if (tradeEditId.value) {
await accountApi.updateTrade(tradeEditId.value, payload)
} else {
await accountApi.addTrade(tradeHolding.value.id, payload)
}
tradeModal.value = false
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(async () => {
await load()
// 투자 계좌를 펼치면 종목코드 있는 종목 현재가를 자동으로 시세 갱신
if (holdings.value.some((h) => h.ticker)) {
refreshPrices()
}
})
</script>
<template>
<div class="portfolio">
<div class="pf-head">
<span class="pf-title">보유 종목</span>
<div class="pf-head-btns">
<button
type="button" class="refresh-btn"
:disabled="refreshing || !holdings.length"
@click="refreshPrices"
>{{ refreshing ? '갱신 중…' : '↻ 시세 갱신' }}</button>
<IconBtn icon="plus" title="종목 추가" variant="primary" size="sm" @click="openAddHolding" />
</div>
</div>
<p v-if="refreshMsg" class="pf-refresh-msg">{{ refreshMsg }}</p>
<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">
<div class="h-sub-qty">{{ shares(h.quantity) }}</div>
<div class="h-sub-price">
평단 {{ won(h.avgPrice) }}
<template v-if="h.currentPrice != null"> · 현재가 {{ won(h.currentPrice) }}</template>
<span v-else class="noprice"> · 현재가 미입력</span>
</div>
</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>
<!-- 매매이력 (최근 N건, 전체는 모달) -->
<div v-if="openTradesId === h.id" class="trade-drop">
<template v-if="tradesOf(h).length">
<ul class="trade-list">
<li v-for="t in recentTrades(h)" :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 icon="edit" title="매매 수정" size="sm" @click="openEditTrade(h, t)" />
<IconBtn class="t-del" icon="trash" title="매매 삭제" variant="danger" size="sm" @click="removeTrade(t, h.id)" />
</li>
</ul>
<button v-if="tradesOf(h).length > RECENT_N" type="button" class="more-trades" @click="openAllTrades(h)">
전체 {{ tradesOf(h).length }} 보기
</button>
</template>
<p v-else class="pf-msg sm">매매 내역이 없습니다.</p>
</div>
</li>
</ul>
<!-- 종목 추가/수정 모달 -->
<AppModal v-model="holdingModal" :title="`종목 ${holdingEditId ? '수정' : '추가'}`">
<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 class="pf-form-hint">종목코드(6자리) 입력하면 <b>시세 갱신</b> 버튼으로 현재가를 자동으로 불러옵니다.</p>
<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>
</AppModal>
<!-- 매매 모달 (매매내역 위에 표시되도록 z-index 높게) -->
<AppModal v-model="tradeModal" :title="`${tradeHolding?.name} ${tradeEditId ? '매매 수정' : (tForm.tradeType === 'SELL' ? '매도' : '매수')}`" :zIndex="1100">
<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="tradeEditId ? '수정' : '등록'" variant="primary" type="submit" :disabled="tSubmitting" />
</div>
</form>
</AppModal>
<!-- 전체 매매내역 모달 -->
<AppModal v-model="allTradesModal" :title="`${allTradesHolding?.name} 매매내역`" :zIndex="1050">
<ul class="trade-list modal-trades">
<li v-for="t in allTradesList" :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 icon="edit" title="매매 수정" size="sm" @click="openEditTrade(allTradesHolding, t)" />
<IconBtn class="t-del" icon="trash" title="매매 삭제" variant="danger" size="sm" @click="removeTrade(t, allTradesHolding.id)" />
</li>
</ul>
</AppModal>
</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-head-btns {
display: flex;
align-items: center;
gap: 0.4rem;
}
.refresh-btn {
padding: 0.25rem 0.6rem;
font-size: 0.78rem;
border: 1px solid var(--color-border);
border-radius: 4px;
background: var(--color-background-soft);
color: var(--color-text);
cursor: pointer;
white-space: nowrap;
}
.refresh-btn:disabled {
opacity: 0.5;
cursor: default;
}
.pf-refresh-msg {
font-size: 0.78rem;
opacity: 0.7;
margin: 0 0 0.4rem;
}
.pf-form-hint {
font-size: 0.76rem;
opacity: 0.6;
margin: -0.2rem 0 0;
}
.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;
}
.h-sub-qty {
font-weight: 600;
}
.h-sub-price {
margin-top: 1px;
}
.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;
}
.more-trades {
margin-top: 0.2rem;
padding: 0.25rem 0.6rem;
font-size: 0.76rem;
border: 1px solid var(--color-border);
border-radius: 4px;
background: var(--color-background-soft);
color: var(--color-text);
cursor: pointer;
}
.more-trades:hover {
border-color: var(--color-border-hover);
}
.modal-trades {
margin-top: 0.5rem;
}
.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;
}
.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);
}
/* ===== 모바일: 매매이력 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>