harb/web-app/src/views/PositionView.vue

474 lines
14 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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&amp;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>