Merge branch 'dev'
Deploy / deploy (push) Failing after 12m7s
CI / build (push) Failing after 13m27s

This commit is contained in:
ByungCheol
2026-06-06 16:42:47 +09:00
3 changed files with 131 additions and 27 deletions
@@ -5,6 +5,7 @@ import android.view.View;
import androidx.core.graphics.Insets; import androidx.core.graphics.Insets;
import androidx.core.view.ViewCompat; import androidx.core.view.ViewCompat;
import androidx.core.view.WindowCompat;
import androidx.core.view.WindowInsetsCompat; import androidx.core.view.WindowInsetsCompat;
import com.getcapacitor.BridgeActivity; import com.getcapacitor.BridgeActivity;
@@ -15,16 +16,16 @@ public class MainActivity extends BridgeActivity {
registerPlugin(CardNotifPlugin.class); registerPlugin(CardNotifPlugin.class);
super.onCreate(savedInstanceState); super.onCreate(savedInstanceState);
// targetSdk 35+ 의 에지투에지 강제 환경에서 웹뷰가 상태바/소프트키(시스템 내비) 뒤로 // targetSdk 35+ 의 에지투에지 강제 환경에서 콘텐츠가 상태바/소프트키 뒤로 깔리는 것을 방지.
// 깔리는 것을 방지한다. 시스템 바 인셋만큼 웹뷰에 패딩을 줘서 콘텐츠(하단 고정 내비 포함)가 // 구글 권장 패턴: 콘텐츠 루트에 시스템 바 인셋만큼 패딩을 적용하고 인셋을 소비(CONSUMED)한다.
// 항상 안전 영역 안에 오도록 한다. (CSS env(safe-area-*) 가 기기별로 0 을 반환하는 문제 회피) // → 헤더는 상태바 아래, 하단 고정 내비는 소프트키 위에 위치.
final View webView = getBridge().getWebView(); WindowCompat.setDecorFitsSystemWindows(getWindow(), false);
if (webView != null) { final View content = findViewById(android.R.id.content);
ViewCompat.setOnApplyWindowInsetsListener(webView, (v, insets) -> { ViewCompat.setOnApplyWindowInsetsListener(content, (v, windowInsets) -> {
Insets bars = insets.getInsets(WindowInsetsCompat.Type.systemBars()); Insets bars = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars());
v.setPadding(bars.left, bars.top, bars.right, bars.bottom); v.setPadding(bars.left, bars.top, bars.right, bars.bottom);
return insets; return WindowInsetsCompat.CONSUMED;
}); });
} ViewCompat.requestApplyInsets(content);
} }
} }
+3
View File
@@ -121,6 +121,9 @@ export const accountApi = {
addTrade(holdingId, payload) { addTrade(holdingId, payload) {
return http.post(`/account/invest/holdings/${holdingId}/trades`, payload) return http.post(`/account/invest/holdings/${holdingId}/trades`, payload)
}, },
updateTrade(id, payload) {
return http.put(`/account/invest/trades/${id}`, payload)
},
removeTrade(id) { removeTrade(id) {
return http.delete(`/account/invest/trades/${id}`) return http.delete(`/account/invest/trades/${id}`)
}, },
+107 -7
View File
@@ -19,6 +19,7 @@ const hError = ref(null)
// 매매 모달 // 매매 모달
const tradeModal = ref(false) const tradeModal = ref(false)
const tradeHolding = ref(null) const tradeHolding = ref(null)
const tradeEditId = ref(null) // null=추가, 값=해당 매매 수정
// inputMode: 'PRICE'(수량×단가) | 'AMOUNT'(수량+총금액 → 단가 자동계산, 소수점 매수용) // inputMode: 'PRICE'(수량×단가) | 'AMOUNT'(수량+총금액 → 단가 자동계산, 소수점 매수용)
const tForm = reactive({ tradeType: 'BUY', tradeDate: '', quantity: null, price: null, amount: null, fee: null, inputMode: 'PRICE' }) const tForm = reactive({ tradeType: 'BUY', tradeDate: '', quantity: null, price: null, amount: null, fee: null, inputMode: 'PRICE' })
const tSubmitting = ref(false) const tSubmitting = ref(false)
@@ -38,9 +39,26 @@ const tradeAmount = computed(() => {
return Math.round(tradeQty.value * (Number(tForm.price) || 0)) return Math.round(tradeQty.value * (Number(tForm.price) || 0))
}) })
// 매매이력 드롭다운 // 매매이력 드롭다운 — 인라인은 최근 N건, 전체는 모달
const openTradesId = ref(null) const openTradesId = ref(null)
const tradesByHolding = reactive({}) 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) { function won(n) {
return (n ?? 0).toLocaleString('ko-KR') return (n ?? 0).toLocaleString('ko-KR')
@@ -58,6 +76,13 @@ function tDate(v) {
const d = new Date(v) const d = new Date(v)
return Number.isNaN(d.getTime()) ? v : `${String(d.getMonth() + 1).padStart(2, '0')}.${String(d.getDate()).padStart(2, '0')}` 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() { async function load() {
loading.value = true loading.value = true
@@ -143,10 +168,27 @@ async function removeHolding(h) {
/* ===== 매매 ===== */ /* ===== 매매 ===== */
function openTrade(h, type) { function openTrade(h, type) {
tradeHolding.value = h 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' }) Object.assign(tForm, { tradeType: type, tradeDate: todayStr(), quantity: null, price: h.currentPrice ?? null, amount: null, fee: null, inputMode: 'PRICE' })
tError.value = null tError.value = null
tradeModal.value = true 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() { async function submitTrade() {
tError.value = null tError.value = null
if (!tForm.quantity || tForm.quantity <= 0) { if (!tForm.quantity || tForm.quantity <= 0) {
@@ -171,9 +213,13 @@ async function submitTrade() {
fee: tForm.fee ? Number(tForm.fee) : 0, fee: tForm.fee ? Number(tForm.fee) : 0,
} }
try { try {
if (tradeEditId.value) {
await accountApi.updateTrade(tradeEditId.value, payload)
} else {
await accountApi.addTrade(tradeHolding.value.id, payload) await accountApi.addTrade(tradeHolding.value.id, payload)
}
tradeModal.value = false tradeModal.value = false
if (openTradesId.value === tradeHolding.value.id) await loadTrades(tradeHolding.value.id) await loadTrades(tradeHolding.value.id) // 인라인 최근목록·전체보기 모달 동시 갱신
await load() await load()
emit('changed') emit('changed')
} catch (e) { } catch (e) {
@@ -269,18 +315,24 @@ onMounted(async () => {
</div> </div>
</div> </div>
<!-- 매매이력 --> <!-- 매매이력 (최근 N건, 전체는 모달) -->
<div v-if="openTradesId === h.id" class="trade-drop"> <div v-if="openTradesId === h.id" class="trade-drop">
<ul v-if="(tradesByHolding[h.id] || []).length" class="trade-list"> <template v-if="tradesOf(h).length">
<li v-for="t in tradesByHolding[h.id]" :key="t.id" class="trade-row"> <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-date">{{ tDate(t.tradeDate) }}</span>
<span class="t-type" :class="t.tradeType === 'SELL' ? 'sell' : 'buy'">{{ t.tradeType === 'SELL' ? '매도' : '매수' }}</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-qty">{{ shares(t.quantity) }} × {{ won(t.price) }}</span>
<span class="t-fee" v-if="t.fee">수수료 {{ won(t.fee) }}</span> <span class="t-fee" v-if="t.fee">수수료 {{ won(t.fee) }}</span>
<span class="t-amt">{{ won(t.amount) }}</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)" /> <IconBtn class="t-del" icon="trash" title="매매 삭제" variant="danger" size="sm" @click="removeTrade(t, h.id)" />
</li> </li>
</ul> </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> <p v-else class="pf-msg sm">매매 내역이 없습니다.</p>
</div> </div>
</li> </li>
@@ -315,7 +367,7 @@ onMounted(async () => {
<div v-if="tradeModal" class="modal-backdrop" @click.self="tradeModal = false"> <div v-if="tradeModal" class="modal-backdrop" @click.self="tradeModal = false">
<div class="modal" role="dialog" aria-modal="true"> <div class="modal" role="dialog" aria-modal="true">
<button class="close" type="button" @click="tradeModal = false">×</button> <button class="close" type="button" @click="tradeModal = false">×</button>
<h2>{{ tradeHolding?.name }} {{ tForm.tradeType === 'SELL' ? '매도' : '매수' }}</h2> <h2>{{ tradeHolding?.name }} {{ tradeEditId ? '매매 수정' : (tForm.tradeType === 'SELL' ? '매도' : '매수') }}</h2>
<form class="pf-form" @submit.prevent="submitTrade"> <form class="pf-form" @submit.prevent="submitTrade">
<label>구분 <label>구분
<select v-model="tForm.tradeType" :disabled="tSubmitting"> <select v-model="tForm.tradeType" :disabled="tSubmitting">
@@ -344,13 +396,36 @@ onMounted(async () => {
<p v-if="tError" class="pf-msg err">{{ tError }}</p> <p v-if="tError" class="pf-msg err">{{ tError }}</p>
<div class="pf-buttons"> <div class="pf-buttons">
<IconBtn icon="close" title="취소" @click="tradeModal = false" /> <IconBtn icon="close" title="취소" @click="tradeModal = false" />
<IconBtn icon="save" title="등록" variant="primary" type="submit" :disabled="tSubmitting" /> <IconBtn icon="save" :title="tradeEditId ? '수정' : '등록'" variant="primary" type="submit" :disabled="tSubmitting" />
</div> </div>
</form> </form>
</div> </div>
</div> </div>
</Transition> </Transition>
</Teleport> </Teleport>
<!-- 전체 매매내역 모달 (스크롤) -->
<Teleport to="body">
<Transition name="fade">
<div v-if="allTradesModal" class="modal-backdrop trades-backdrop" @click.self="allTradesModal = false">
<div class="modal trades-modal" role="dialog" aria-modal="true">
<button class="close" type="button" @click="allTradesModal = false">×</button>
<h2>{{ allTradesHolding?.name }} 매매내역</h2>
<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>
</div>
</div>
</Transition>
</Teleport>
</div> </div>
</template> </template>
@@ -520,6 +595,31 @@ onMounted(async () => {
.trade-drop { .trade-drop {
padding: 0.1rem 0 0.5rem 1.4rem; 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-backdrop.trades-backdrop {
z-index: 1090;
}
.trades-modal {
max-width: 420px;
}
.modal-trades {
max-height: 60vh;
overflow-y: auto;
margin-top: 0.5rem;
}
.trade-list { .trade-list {
list-style: none; list-style: none;
} }