mgv-backoffice 1.11.0 → 1.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -1
- package/dist/src/components/BaseButton.vue.d.ts +22 -0
- package/dist/src/components/BaseConfirmModal.vue.d.ts +16 -1
- package/dist/src/components/EarningsCard.vue.d.ts +11 -0
- package/dist/src/enums/BaseButtonEnum.d.ts +5 -1
- package/dist/ui-lib.css +1 -1
- package/dist/ui-lib.js +391 -246
- package/dist/ui-lib.umd.cjs +1 -1
- package/package.json +2 -1
- package/src/components/BaseButton.vue +172 -2
- package/src/components/BaseConfirmModal.vue +7 -0
- package/src/components/EarningsCard.test.ts +33 -0
- package/src/components/EarningsCard.vue +64 -10
- package/src/enums/BaseButtonEnum.ts +4 -0
- package/src/utils/pnl.test.ts +6 -0
- package/src/utils/pnl.ts +6 -1
- package/tailwind.safelist.css +17 -1
- package/tailwind.safelist.js +17 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { mount } from '@vue/test-utils'
|
|
3
|
+
import EarningsCard from './EarningsCard.vue'
|
|
4
|
+
|
|
5
|
+
describe('EarningsCard', () => {
|
|
6
|
+
it('formats a finite amount with currency and decimals', () => {
|
|
7
|
+
const w = mount(EarningsCard, { props: { amount: 1234.5, currency: '$', decimals: 2 } })
|
|
8
|
+
expect(w.text()).toContain('$1,234.50')
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
it('falls back to a zeroed amount when given NaN (never renders "NaN")', () => {
|
|
12
|
+
const w = mount(EarningsCard, { props: { amount: Number.NaN } })
|
|
13
|
+
expect(w.text()).not.toContain('NaN')
|
|
14
|
+
expect(w.text()).toContain('$0.00')
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('falls back to a zeroed amount when given Infinity', () => {
|
|
18
|
+
const w = mount(EarningsCard, { props: { amount: Number.POSITIVE_INFINITY } })
|
|
19
|
+
expect(w.text()).not.toContain('Infinity')
|
|
20
|
+
expect(w.text()).toContain('$0.00')
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('defaults to the orange accent for backward compatibility', () => {
|
|
24
|
+
const w = mount(EarningsCard, { props: { amount: 1 } })
|
|
25
|
+
expect(w.html()).toContain('border-orange-300')
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('applies the emerald accent when requested', () => {
|
|
29
|
+
const w = mount(EarningsCard, { props: { amount: 1, accent: 'emerald' } })
|
|
30
|
+
expect(w.html()).toContain('border-emerald-300')
|
|
31
|
+
expect(w.html()).not.toContain('border-orange-300')
|
|
32
|
+
})
|
|
33
|
+
})
|
|
@@ -1,6 +1,16 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { computed } from 'vue'
|
|
3
3
|
|
|
4
|
+
/**
|
|
5
|
+
* Accent colour of the card. Defaults to `orange` so existing consumers are
|
|
6
|
+
* unaffected; `emerald` lets a host app tint the card to a green-themed brand.
|
|
7
|
+
* Each accent carries the full literal Tailwind class strings (rather than a
|
|
8
|
+
* single interpolated hue) so the classes are statically detectable and the
|
|
9
|
+
* two themes can differ in more than hue — e.g. orange renders the amount in
|
|
10
|
+
* neutral grey while emerald renders it in the accent itself.
|
|
11
|
+
*/
|
|
12
|
+
type Accent = 'orange' | 'emerald'
|
|
13
|
+
|
|
4
14
|
interface Props {
|
|
5
15
|
title?: string
|
|
6
16
|
amount?: number
|
|
@@ -8,6 +18,7 @@ interface Props {
|
|
|
8
18
|
badge?: string
|
|
9
19
|
currency?: string
|
|
10
20
|
decimals?: number
|
|
21
|
+
accent?: Accent
|
|
11
22
|
}
|
|
12
23
|
|
|
13
24
|
const props = withDefaults(defineProps<Props>(), {
|
|
@@ -17,22 +28,61 @@ const props = withDefaults(defineProps<Props>(), {
|
|
|
17
28
|
badge: '',
|
|
18
29
|
currency: '$',
|
|
19
30
|
decimals: 2,
|
|
31
|
+
accent: 'orange',
|
|
20
32
|
})
|
|
21
33
|
|
|
34
|
+
const ACCENTS: Record<Accent, {
|
|
35
|
+
border: string
|
|
36
|
+
badge: string
|
|
37
|
+
amount: string
|
|
38
|
+
subtitle: string
|
|
39
|
+
iconBox: string
|
|
40
|
+
icon: string
|
|
41
|
+
}> = {
|
|
42
|
+
orange: {
|
|
43
|
+
border: 'border-orange-300 dark:border-orange-700',
|
|
44
|
+
badge: 'bg-orange-100 text-orange-500 dark:bg-orange-900/40 dark:text-orange-400',
|
|
45
|
+
amount: 'text-gray-400 dark:text-slate-300',
|
|
46
|
+
subtitle: 'text-orange-400 dark:text-orange-400',
|
|
47
|
+
iconBox: 'bg-orange-50 dark:bg-orange-900/30',
|
|
48
|
+
icon: 'text-orange-400',
|
|
49
|
+
},
|
|
50
|
+
emerald: {
|
|
51
|
+
border: 'border-emerald-300 dark:border-emerald-700',
|
|
52
|
+
badge: 'bg-emerald-100 text-emerald-600 dark:bg-emerald-900/40 dark:text-emerald-400',
|
|
53
|
+
amount: 'text-emerald-500 dark:text-emerald-400',
|
|
54
|
+
subtitle: 'text-emerald-500 dark:text-emerald-400',
|
|
55
|
+
iconBox: 'bg-emerald-50 dark:bg-emerald-900/30',
|
|
56
|
+
icon: 'text-emerald-500',
|
|
57
|
+
},
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const cls = computed(() => ACCENTS[props.accent])
|
|
61
|
+
|
|
22
62
|
const formattedAmount = computed(() => {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
63
|
+
// Guard against NaN/Infinity reaching toLocaleString (which would render the
|
|
64
|
+
// literal "NaN"); fall back to a zeroed amount so the card stays readable.
|
|
65
|
+
const amount = Number.isFinite(props.amount) ? props.amount : 0
|
|
66
|
+
return (
|
|
67
|
+
props.currency +
|
|
68
|
+
amount.toLocaleString('en-US', {
|
|
69
|
+
minimumFractionDigits: props.decimals,
|
|
70
|
+
maximumFractionDigits: props.decimals,
|
|
71
|
+
})
|
|
72
|
+
)
|
|
27
73
|
})
|
|
28
74
|
</script>
|
|
29
75
|
|
|
30
76
|
<template>
|
|
31
|
-
<div
|
|
77
|
+
<div
|
|
78
|
+
class="relative rounded-xl border-2 border-dashed bg-white p-6 dark:bg-slate-800"
|
|
79
|
+
:class="cls.border"
|
|
80
|
+
>
|
|
32
81
|
<!-- Badge -->
|
|
33
82
|
<div
|
|
34
83
|
v-if="badge"
|
|
35
|
-
class="absolute right-4 top-4 rounded-md
|
|
84
|
+
class="absolute right-4 top-4 rounded-md px-3 py-1 text-xs font-bold tracking-wide"
|
|
85
|
+
:class="cls.badge"
|
|
36
86
|
>
|
|
37
87
|
{{ badge }}
|
|
38
88
|
</div>
|
|
@@ -46,20 +96,24 @@ const formattedAmount = computed(() => {
|
|
|
46
96
|
</p>
|
|
47
97
|
|
|
48
98
|
<!-- Amount -->
|
|
49
|
-
<p class="mt-1 text-4xl font-bold
|
|
99
|
+
<p class="mt-1 text-4xl font-bold" :class="cls.amount">
|
|
50
100
|
{{ formattedAmount }}
|
|
51
101
|
</p>
|
|
52
102
|
|
|
53
103
|
<!-- Subtitle -->
|
|
54
|
-
<p class="mt-1 text-sm font-medium
|
|
104
|
+
<p class="mt-1 text-sm font-medium" :class="cls.subtitle">
|
|
55
105
|
{{ subtitle }}
|
|
56
106
|
</p>
|
|
57
107
|
</div>
|
|
58
108
|
|
|
59
109
|
<!-- Chart icon -->
|
|
60
|
-
<div
|
|
110
|
+
<div
|
|
111
|
+
class="mt-6 flex h-12 w-12 items-center justify-center rounded-xl"
|
|
112
|
+
:class="cls.iconBox"
|
|
113
|
+
>
|
|
61
114
|
<svg
|
|
62
|
-
class="h-6 w-6
|
|
115
|
+
class="h-6 w-6"
|
|
116
|
+
:class="cls.icon"
|
|
63
117
|
viewBox="0 0 24 24"
|
|
64
118
|
fill="none"
|
|
65
119
|
stroke="currentColor"
|
package/src/utils/pnl.test.ts
CHANGED
|
@@ -42,4 +42,10 @@ describe('computePnL', () => {
|
|
|
42
42
|
expect(computePnL({ buyPrice: 0, lastPrice: 110, filledQty: 5 })).toEqual(nulls)
|
|
43
43
|
expect(computePnL({ buyPrice: -5, lastPrice: 110, filledQty: 5 })).toEqual(nulls)
|
|
44
44
|
})
|
|
45
|
+
|
|
46
|
+
it('returns nulls when lastPrice is not positive (corrupt row / bad quote, not a real -100% loss)', () => {
|
|
47
|
+
const nulls = { pnlUsd: null, pnlPct: null }
|
|
48
|
+
expect(computePnL({ buyPrice: 100, lastPrice: 0, filledQty: 2 })).toEqual(nulls)
|
|
49
|
+
expect(computePnL({ buyPrice: 100, lastPrice: -5, filledQty: 2 })).toEqual(nulls)
|
|
50
|
+
})
|
|
45
51
|
})
|
package/src/utils/pnl.ts
CHANGED
|
@@ -30,7 +30,12 @@ export function computePnL(row: PnLInputs): PnL {
|
|
|
30
30
|
const buy = Number(row.buyPrice)
|
|
31
31
|
const last = Number(row.lastPrice)
|
|
32
32
|
const qty = Number(row.filledQty)
|
|
33
|
-
|
|
33
|
+
// `last <= 0` mirrors the `buy <= 0` guard: a real traded price is always
|
|
34
|
+
// positive, so a zero/negative last price means a corrupt row or a bad quote
|
|
35
|
+
// parse — never an asset that genuinely went to zero. Without this, such a row
|
|
36
|
+
// yields pnlUsd = (0 - buy) * qty = -notional, a phantom ~100% loss that
|
|
37
|
+
// silently drags portfolio totals far negative. Treat it as unpriced (null).
|
|
38
|
+
if (!isFinite(buy) || !isFinite(last) || !isFinite(qty) || buy <= 0 || last <= 0) {
|
|
34
39
|
return { pnlUsd: null, pnlPct: null }
|
|
35
40
|
}
|
|
36
41
|
return {
|
package/tailwind.safelist.css
CHANGED
|
@@ -43,9 +43,25 @@
|
|
|
43
43
|
bg-red-700 hover:bg-red-800 focus:ring-red-300 bg-red-400
|
|
44
44
|
bg-yellow-400 hover:bg-yellow-500 focus:ring-yellow-300
|
|
45
45
|
bg-purple-700 hover:bg-purple-800 focus:ring-purple-300 bg-purple-400
|
|
46
|
+
bg-emerald-600 hover:bg-emerald-700 focus:ring-emerald-300 bg-emerald-400
|
|
47
|
+
bg-sky-600 hover:bg-sky-700 focus:ring-sky-300 bg-sky-400
|
|
48
|
+
bg-amber-500 hover:bg-amber-600 focus:ring-amber-300 bg-amber-300
|
|
49
|
+
bg-gray-600 hover:bg-gray-700
|
|
46
50
|
px-5 py-2.5 mr-2 mb-2 py-2 text-base py-3 px-6 py-3.5
|
|
47
|
-
font-medium rounded-lg rounded-full cursor-not-allowed
|
|
51
|
+
font-medium rounded-lg rounded-full cursor-not-allowed cursor-pointer
|
|
48
52
|
inline w-4 h-4 me-3 animate-spin
|
|
53
|
+
border bg-transparent opacity-50 opacity-60
|
|
54
|
+
text-{blue,green,emerald,sky,red,amber,yellow,purple}-700
|
|
55
|
+
border-{blue,green,emerald,sky,red,amber,yellow,purple}-300
|
|
56
|
+
hover:bg-{blue,green,emerald,sky,red,amber,yellow,purple}-50
|
|
57
|
+
text-{blue,green,emerald,sky,red,amber,yellow,purple}-400
|
|
58
|
+
border-{blue,green,emerald,sky,red,amber,yellow,purple}-500/40
|
|
59
|
+
hover:bg-{blue,green,emerald,sky,red,amber,yellow,purple}-500/10
|
|
60
|
+
text-{blue,green,emerald,sky,yellow,purple}-600
|
|
61
|
+
text-red-500 text-amber-300
|
|
62
|
+
hover:bg-gray-100
|
|
63
|
+
text-gray-700 border-gray-300 hover:bg-gray-50
|
|
64
|
+
text-gray-500 text-gray-400 text-gray-300 border-gray-600 hover:bg-gray-800
|
|
49
65
|
");
|
|
50
66
|
|
|
51
67
|
/* ── BaseToast ────────────────────────────────────────────── */
|
package/tailwind.safelist.js
CHANGED
|
@@ -97,6 +97,23 @@ const safelist = [
|
|
|
97
97
|
'hover:bg-purple-800',
|
|
98
98
|
'focus:ring-purple-300',
|
|
99
99
|
'bg-purple-400',
|
|
100
|
+
// EMERALD/SKY/AMBER/GRAY (filled) + outline + ghost variants
|
|
101
|
+
'bg-emerald-600', 'hover:bg-emerald-700', 'focus:ring-emerald-300', 'bg-emerald-400',
|
|
102
|
+
'bg-sky-600', 'hover:bg-sky-700', 'focus:ring-sky-300', 'bg-sky-400',
|
|
103
|
+
'bg-amber-500', 'hover:bg-amber-600', 'focus:ring-amber-300', 'bg-amber-300',
|
|
104
|
+
'bg-gray-600', 'hover:bg-gray-700',
|
|
105
|
+
'border', 'bg-transparent', 'opacity-50', 'opacity-60', 'cursor-pointer',
|
|
106
|
+
// outline tones
|
|
107
|
+
...['blue', 'green', 'emerald', 'sky', 'red', 'amber', 'yellow', 'purple'].flatMap(c => [
|
|
108
|
+
`text-${c}-700`, `border-${c}-300`, `hover:bg-${c}-50`,
|
|
109
|
+
`text-${c}-400`, `border-${c}-500/40`, `hover:bg-${c}-500/10`,
|
|
110
|
+
]),
|
|
111
|
+
// ghost tones (light 500/600 text, dark 300/400 text)
|
|
112
|
+
...['blue', 'green', 'emerald', 'sky', 'yellow', 'purple'].flatMap(c => [`text-${c}-600`]),
|
|
113
|
+
'text-red-500', 'text-amber-300',
|
|
114
|
+
'hover:bg-gray-100',
|
|
115
|
+
'text-gray-700', 'border-gray-300', 'hover:bg-gray-50',
|
|
116
|
+
'text-gray-500', 'text-gray-400', 'text-gray-300', 'border-gray-600', 'hover:bg-gray-800',
|
|
100
117
|
// Dynamic: switch on size
|
|
101
118
|
'px-5',
|
|
102
119
|
'py-2.5',
|