474 lines
14 KiB
Vue
474 lines
14 KiB
Vue
<template>
|
||
<div class="position-view">
|
||
<!-- Loading / Error -->
|
||
<div v-if="loading && !position" class="pos-loading">
|
||
<div class="spinner"></div>
|
||
<p>Loading position…</p>
|
||
</div>
|
||
<div v-if="error" class="pos-error">⚠️ {{ error }}</div>
|
||
|
||
<template v-if="position">
|
||
<!-- Header -->
|
||
<div class="pos-header">
|
||
<div class="pos-header__left">
|
||
<div class="pos-header__id">Position #{{ position.id }}</div>
|
||
<span class="status-badge" :class="position.status === 'Active' ? 'status-badge--active' : 'status-badge--closed'">
|
||
{{ position.status }}
|
||
</span>
|
||
</div>
|
||
<div class="pos-header__owner">
|
||
<span class="pos-header__owner-label">Owner</span>
|
||
<RouterLink :to="{ name: 'wallet', params: { address: position.owner } }" class="pos-header__owner-addr">
|
||
{{ truncateAddr(position.owner) }}
|
||
</RouterLink>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Overview Cards -->
|
||
<div class="overview-cards">
|
||
<div class="ov-card">
|
||
<div class="ov-card__label">Deposited</div>
|
||
<div class="ov-card__value">{{ fmtKrk(depositKrk) }}</div>
|
||
<div class="ov-card__unit">KRK</div>
|
||
</div>
|
||
<div class="ov-card">
|
||
<div class="ov-card__label">Current Value</div>
|
||
<div class="ov-card__value">{{ fmtKrk(currentValueKrk) }}</div>
|
||
<div class="ov-card__unit">KRK</div>
|
||
</div>
|
||
<div class="ov-card">
|
||
<div class="ov-card__label">Tax Paid</div>
|
||
<div class="ov-card__value">{{ fmtKrk(taxPaidKrk) }}</div>
|
||
<div class="ov-card__unit">KRK</div>
|
||
</div>
|
||
<div class="ov-card" :class="netReturnKrk >= 0 ? 'ov-card--positive' : 'ov-card--negative'">
|
||
<div class="ov-card__label">Net Return</div>
|
||
<div class="ov-card__value">{{ netReturnKrk >= 0 ? '+' : '' }}{{ fmtKrk(netReturnKrk) }}</div>
|
||
<div class="ov-card__unit">KRK</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Two-column detail + metrics -->
|
||
<div class="detail-grid">
|
||
<!-- Position Details -->
|
||
<div class="detail-panel">
|
||
<h3 class="panel-title">Position Details</h3>
|
||
<div class="detail-rows">
|
||
<div class="detail-row">
|
||
<span class="detail-row__label">Tax Rate</span>
|
||
<span class="detail-row__value"
|
||
>{{ taxRatePercent.toFixed(2) }}% <span class="muted">(index {{ position.taxRateIndex }})</span></span
|
||
>
|
||
</div>
|
||
<div class="detail-row">
|
||
<span class="detail-row__label">Created</span>
|
||
<span class="detail-row__value">{{ createdFormatted }}</span>
|
||
</div>
|
||
<div class="detail-row">
|
||
<span class="detail-row__label">Time Held</span>
|
||
<span class="detail-row__value">{{ timeHeld }}</span>
|
||
</div>
|
||
<div class="detail-row">
|
||
<span class="detail-row__label">Last Tax Payment</span>
|
||
<span class="detail-row__value">{{ lastTaxFormatted }}</span>
|
||
</div>
|
||
<div class="detail-row">
|
||
<span class="detail-row__label">Times Snatched</span>
|
||
<span class="detail-row__value">{{ position.snatched }}</span>
|
||
</div>
|
||
<template v-if="position.status === 'Closed'">
|
||
<div class="detail-row">
|
||
<span class="detail-row__label">Closed At</span>
|
||
<span class="detail-row__value">{{ closedAtFormatted }}</span>
|
||
</div>
|
||
<div class="detail-row">
|
||
<span class="detail-row__label">Payout</span>
|
||
<span class="detail-row__value">{{ fmtKrk(payoutKrk) }} KRK</span>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Computed Metrics -->
|
||
<div class="detail-panel">
|
||
<h3 class="panel-title">Metrics</h3>
|
||
<div class="detail-rows">
|
||
<div class="detail-row">
|
||
<span class="detail-row__label">Daily Tax Cost</span>
|
||
<span class="detail-row__value">{{ fmtKrk(dailyTaxCost) }} KRK/day</span>
|
||
</div>
|
||
<div class="detail-row">
|
||
<span class="detail-row__label">Share of Pool</span>
|
||
<span class="detail-row__value">{{ sharePercent.toFixed(4) }}%</span>
|
||
</div>
|
||
<div class="detail-row">
|
||
<span class="detail-row__label">Days to Breakeven</span>
|
||
<span class="detail-row__value">{{ breakevenDays }}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Snatch Risk -->
|
||
<div class="snatch-risk" :style="{ '--risk-color': snatchRisk.color }">
|
||
<div class="snatch-risk__title">Snatch Risk</div>
|
||
<div class="snatch-risk__badge">{{ snatchRisk.level }}</div>
|
||
<div class="snatch-risk__detail">{{ snatchRisk.count }} position{{ snatchRisk.count !== 1 ? 's' : '' }} at lower tax rates</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Closed Summary -->
|
||
<div v-if="position.status === 'Closed'" class="closed-summary">
|
||
<h3 class="panel-title">Final Summary</h3>
|
||
<div class="closed-grid">
|
||
<div class="closed-item">
|
||
<span class="closed-item__label">Deposited</span>
|
||
<span class="closed-item__value">{{ fmtKrk(depositKrk) }} KRK</span>
|
||
</div>
|
||
<div class="closed-item">
|
||
<span class="closed-item__label">Payout</span>
|
||
<span class="closed-item__value">{{ fmtKrk(payoutKrk) }} KRK</span>
|
||
</div>
|
||
<div class="closed-item">
|
||
<span class="closed-item__label">Tax Paid</span>
|
||
<span class="closed-item__value">{{ fmtKrk(taxPaidKrk) }} KRK</span>
|
||
</div>
|
||
<div class="closed-item" :class="netPnlKrk >= 0 ? 'positive' : 'negative'">
|
||
<span class="closed-item__label">Net P&L</span>
|
||
<span class="closed-item__value">{{ netPnlKrk >= 0 ? '+' : '' }}{{ fmtKrk(netPnlKrk) }} KRK</span>
|
||
</div>
|
||
<div class="closed-item">
|
||
<span class="closed-item__label">Duration</span>
|
||
<span class="closed-item__value">{{ timeHeld }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<div v-else-if="!loading && !error" class="pos-not-found">Position not found.</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { computed } from 'vue';
|
||
import { useRoute, RouterLink } from 'vue-router';
|
||
import { usePositionDashboard } from '@/composables/usePositionDashboard';
|
||
|
||
const route = useRoute();
|
||
const positionId = computed(() => String(route.params.id ?? ''));
|
||
|
||
const {
|
||
loading,
|
||
error,
|
||
position,
|
||
depositKrk,
|
||
taxPaidKrk,
|
||
currentValueKrk,
|
||
netReturnKrk,
|
||
taxRatePercent,
|
||
dailyTaxCost,
|
||
sharePercent,
|
||
createdFormatted,
|
||
lastTaxFormatted,
|
||
closedAtFormatted,
|
||
timeHeld,
|
||
snatchRisk,
|
||
payoutKrk,
|
||
netPnlKrk,
|
||
} = usePositionDashboard(positionId);
|
||
|
||
function fmtKrk(val: number): string {
|
||
if (!Number.isFinite(val)) return '0.00';
|
||
return val.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 4 });
|
||
}
|
||
|
||
function truncateAddr(addr: string): string {
|
||
if (!addr || addr.length < 10) return addr;
|
||
return `${addr.slice(0, 6)}…${addr.slice(-4)}`;
|
||
}
|
||
|
||
// Days to breakeven: (currentValue - deposit) / dailyTaxCost
|
||
// Only meaningful if position is active and dailyTaxCost > 0
|
||
const breakevenDays = computed(() => {
|
||
if (!position.value || position.value.status !== 'Active') return 'N/A';
|
||
const dtc = dailyTaxCost.value;
|
||
if (dtc <= 0) return 'N/A';
|
||
// daily gain ≈ current_value growth — but we don't have real-time growth rate here
|
||
// fallback: show just daily cost relative to net return
|
||
if (netReturnKrk.value >= 0) return 'Already profitable';
|
||
const daysLeft = Math.abs(netReturnKrk.value) / dtc;
|
||
return `~${Math.ceil(daysLeft)} days`;
|
||
});
|
||
</script>
|
||
|
||
<style lang="sass" scoped>
|
||
.position-view
|
||
display: flex
|
||
flex-direction: column
|
||
gap: 28px
|
||
padding: 16px
|
||
max-width: 1200px
|
||
margin: 0 auto
|
||
@media (min-width: 992px)
|
||
padding: 48px
|
||
|
||
// ─── Loading/Error ────────────────────────────────────────────────────────────
|
||
.pos-loading
|
||
display: flex
|
||
align-items: center
|
||
gap: 12px
|
||
color: #9A9898
|
||
padding: 32px
|
||
justify-content: center
|
||
|
||
.pos-error
|
||
background: rgba(248, 113, 113, 0.1)
|
||
border: 1px solid rgba(248, 113, 113, 0.3)
|
||
border-radius: 12px
|
||
padding: 16px
|
||
color: #F87171
|
||
|
||
.pos-not-found
|
||
text-align: center
|
||
color: #9A9898
|
||
padding: 64px
|
||
|
||
.spinner
|
||
width: 20px
|
||
height: 20px
|
||
border: 2px solid rgba(117, 80, 174, 0.3)
|
||
border-top-color: #7550AE
|
||
border-radius: 50%
|
||
animation: spin 0.8s linear infinite
|
||
|
||
@keyframes spin
|
||
to
|
||
transform: rotate(360deg)
|
||
|
||
// ─── Header ───────────────────────────────────────────────────────────────────
|
||
.pos-header
|
||
background: linear-gradient(135deg, rgba(117, 80, 174, 0.15) 0%, rgba(117, 80, 174, 0.05) 100%)
|
||
border: 1px solid rgba(117, 80, 174, 0.3)
|
||
border-radius: 20px
|
||
padding: 24px 32px
|
||
display: flex
|
||
flex-direction: column
|
||
gap: 16px
|
||
@media (min-width: 768px)
|
||
flex-direction: row
|
||
align-items: center
|
||
justify-content: space-between
|
||
|
||
&__left
|
||
display: flex
|
||
align-items: center
|
||
gap: 16px
|
||
|
||
&__id
|
||
font-family: "Audiowide", sans-serif
|
||
font-size: 28px
|
||
color: #ffffff
|
||
@media (min-width: 768px)
|
||
font-size: 36px
|
||
|
||
&__owner
|
||
display: flex
|
||
flex-direction: column
|
||
gap: 4px
|
||
|
||
&__owner-label
|
||
font-size: 11px
|
||
text-transform: uppercase
|
||
letter-spacing: 1px
|
||
color: #9A9898
|
||
|
||
&__owner-addr
|
||
font-family: monospace
|
||
color: #7550AE
|
||
text-decoration: none
|
||
font-size: 15px
|
||
&:hover
|
||
color: #9A70DE
|
||
text-decoration: underline
|
||
|
||
.status-badge
|
||
padding: 6px 14px
|
||
border-radius: 99px
|
||
font-size: 13px
|
||
font-weight: 700
|
||
text-transform: uppercase
|
||
letter-spacing: 0.5px
|
||
|
||
&--active
|
||
background: rgba(74, 222, 128, 0.15)
|
||
color: #4ADE80
|
||
border: 1px solid rgba(74, 222, 128, 0.3)
|
||
|
||
&--closed
|
||
background: rgba(255,255,255,0.08)
|
||
color: #9A9898
|
||
border: 1px solid rgba(255,255,255,0.15)
|
||
|
||
// ─── Overview Cards ───────────────────────────────────────────────────────────
|
||
.overview-cards
|
||
display: flex
|
||
flex-direction: column
|
||
gap: 16px
|
||
@media (min-width: 768px)
|
||
flex-direction: row
|
||
|
||
.ov-card
|
||
flex: 1
|
||
background: #07111B
|
||
border: 1px solid rgba(117, 80, 174, 0.2)
|
||
border-radius: 16px
|
||
padding: 20px 24px
|
||
display: flex
|
||
flex-direction: column
|
||
gap: 4px
|
||
|
||
&--positive
|
||
border-color: rgba(74, 222, 128, 0.3)
|
||
.ov-card__value
|
||
color: #4ADE80
|
||
|
||
&--negative
|
||
border-color: rgba(248, 113, 113, 0.3)
|
||
.ov-card__value
|
||
color: #F87171
|
||
|
||
&__label
|
||
font-size: 11px
|
||
text-transform: uppercase
|
||
letter-spacing: 1px
|
||
color: #9A9898
|
||
|
||
&__value
|
||
font-family: "Audiowide", sans-serif
|
||
font-size: 24px
|
||
color: #ffffff
|
||
line-height: 1.2
|
||
|
||
&__unit
|
||
font-size: 12px
|
||
color: #7550AE
|
||
font-weight: 600
|
||
|
||
// ─── Detail Grid ──────────────────────────────────────────────────────────────
|
||
.detail-grid
|
||
display: flex
|
||
flex-direction: column
|
||
gap: 20px
|
||
@media (min-width: 768px)
|
||
flex-direction: row
|
||
|
||
.detail-panel
|
||
flex: 1
|
||
background: #07111B
|
||
border: 1px solid rgba(117, 80, 174, 0.15)
|
||
border-radius: 16px
|
||
padding: 24px
|
||
display: flex
|
||
flex-direction: column
|
||
gap: 16px
|
||
|
||
.panel-title
|
||
font-size: 16px
|
||
font-weight: 600
|
||
color: #ffffff
|
||
margin: 0
|
||
padding-bottom: 12px
|
||
border-bottom: 1px solid rgba(117, 80, 174, 0.2)
|
||
|
||
.detail-rows
|
||
display: flex
|
||
flex-direction: column
|
||
gap: 12px
|
||
|
||
.detail-row
|
||
display: flex
|
||
justify-content: space-between
|
||
align-items: center
|
||
gap: 8px
|
||
|
||
&__label
|
||
font-size: 13px
|
||
color: #9A9898
|
||
|
||
&__value
|
||
font-size: 13px
|
||
color: #ffffff
|
||
font-weight: 600
|
||
text-align: right
|
||
|
||
.muted
|
||
color: #9A9898
|
||
font-weight: 400
|
||
font-size: 12px
|
||
|
||
// ─── Snatch Risk ──────────────────────────────────────────────────────────────
|
||
.snatch-risk
|
||
margin-top: 8px
|
||
padding: 16px
|
||
border-radius: 12px
|
||
border: 1px solid var(--risk-color, #9A9898)
|
||
background: color-mix(in srgb, var(--risk-color, #9A9898) 10%, transparent)
|
||
display: flex
|
||
flex-direction: column
|
||
gap: 6px
|
||
|
||
&__title
|
||
font-size: 11px
|
||
text-transform: uppercase
|
||
letter-spacing: 1px
|
||
color: #9A9898
|
||
|
||
&__badge
|
||
font-size: 20px
|
||
font-weight: 700
|
||
color: var(--risk-color, #9A9898)
|
||
font-family: "Audiowide", sans-serif
|
||
|
||
&__detail
|
||
font-size: 13px
|
||
color: #9A9898
|
||
|
||
// ─── Closed Summary ───────────────────────────────────────────────────────────
|
||
.closed-summary
|
||
background: #07111B
|
||
border: 1px solid rgba(255,255,255,0.1)
|
||
border-radius: 16px
|
||
padding: 24px
|
||
display: flex
|
||
flex-direction: column
|
||
gap: 16px
|
||
|
||
.closed-grid
|
||
display: flex
|
||
flex-wrap: wrap
|
||
gap: 24px
|
||
|
||
.closed-item
|
||
display: flex
|
||
flex-direction: column
|
||
gap: 4px
|
||
min-width: 120px
|
||
|
||
&.positive
|
||
.closed-item__value
|
||
color: #4ADE80
|
||
|
||
&.negative
|
||
.closed-item__value
|
||
color: #F87171
|
||
|
||
&__label
|
||
font-size: 11px
|
||
text-transform: uppercase
|
||
letter-spacing: 0.5px
|
||
color: #9A9898
|
||
|
||
&__value
|
||
font-family: "Audiowide", sans-serif
|
||
font-size: 18px
|
||
color: #ffffff
|
||
</style>
|