mgv-backoffice 1.9.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 +23 -3
- package/dist/src/components/BaseButton.vue.d.ts +22 -0
- package/dist/src/components/BaseConfirmModal.vue.d.ts +16 -1
- package/dist/src/components/BaseSpinner.vue.d.ts +10 -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 +429 -260
- 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/BaseSpinner.vue +76 -3
- 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 +20 -2
- package/tailwind.safelist.js +28 -2
|
@@ -1,3 +1,76 @@
|
|
|
1
|
-
<template>
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
<template>
|
|
2
|
+
<!--
|
|
3
|
+
Animated loading spinner — a spinning ring with a neutral track and a
|
|
4
|
+
coloured leading arc.
|
|
5
|
+
|
|
6
|
+
Props:
|
|
7
|
+
• size — diameter + ring thickness. 'sm' (16px) is the default and
|
|
8
|
+
reproduces the original spinner; 'md' (24px), 'lg' (32px) and
|
|
9
|
+
'xl' (48px) step up for full-page / empty-state loaders.
|
|
10
|
+
• color — the colour of the spinning arc (`border-t-*`). The track
|
|
11
|
+
stays neutral gray. Defaults to 'blue' to match the original.
|
|
12
|
+
|
|
13
|
+
With no props it renders exactly the legacy spinner, so existing
|
|
14
|
+
`<BaseSpinner />` call sites are unaffected.
|
|
15
|
+
-->
|
|
16
|
+
<div
|
|
17
|
+
class="border-gray-200 rounded-full animate-spin"
|
|
18
|
+
:class="[sizeClass, colorClass]"
|
|
19
|
+
role="status"
|
|
20
|
+
aria-label="Loading"
|
|
21
|
+
></div>
|
|
22
|
+
</template>
|
|
23
|
+
|
|
24
|
+
<script setup lang="ts">
|
|
25
|
+
import { computed } from 'vue'
|
|
26
|
+
|
|
27
|
+
type SpinnerSize = 'sm' | 'md' | 'lg' | 'xl'
|
|
28
|
+
type SpinnerColor =
|
|
29
|
+
| 'blue'
|
|
30
|
+
| 'emerald'
|
|
31
|
+
| 'sky'
|
|
32
|
+
| 'indigo'
|
|
33
|
+
| 'teal'
|
|
34
|
+
| 'purple'
|
|
35
|
+
| 'red'
|
|
36
|
+
| 'amber'
|
|
37
|
+
|
|
38
|
+
const props = withDefaults(
|
|
39
|
+
defineProps<{
|
|
40
|
+
size?: SpinnerSize
|
|
41
|
+
color?: SpinnerColor
|
|
42
|
+
}>(),
|
|
43
|
+
{
|
|
44
|
+
size: 'sm',
|
|
45
|
+
color: 'blue',
|
|
46
|
+
},
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
// Diameter pairs with a proportional ring thickness. Full literal strings
|
|
50
|
+
// so Tailwind detects them at build time.
|
|
51
|
+
const sizeClass = computed(() => {
|
|
52
|
+
const sizes: Record<SpinnerSize, string> = {
|
|
53
|
+
sm: 'w-4 h-4 border-2',
|
|
54
|
+
md: 'w-6 h-6 border-2',
|
|
55
|
+
lg: 'w-8 h-8 border-4',
|
|
56
|
+
xl: 'w-12 h-12 border-4',
|
|
57
|
+
}
|
|
58
|
+
return sizes[props.size]
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
// Only the leading arc (`border-t-*`) is tinted; the rest of the ring is
|
|
62
|
+
// the neutral gray track from the template.
|
|
63
|
+
const colorClass = computed(() => {
|
|
64
|
+
const colors: Record<SpinnerColor, string> = {
|
|
65
|
+
blue: 'border-t-blue-500',
|
|
66
|
+
emerald: 'border-t-emerald-500',
|
|
67
|
+
sky: 'border-t-sky-500',
|
|
68
|
+
indigo: 'border-t-indigo-500',
|
|
69
|
+
teal: 'border-t-teal-500',
|
|
70
|
+
purple: 'border-t-purple-500',
|
|
71
|
+
red: 'border-t-red-500',
|
|
72
|
+
amber: 'border-t-amber-500',
|
|
73
|
+
}
|
|
74
|
+
return colors[props.color]
|
|
75
|
+
})
|
|
76
|
+
</script>
|
|
@@ -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 ────────────────────────────────────────────── */
|
|
@@ -86,7 +102,9 @@
|
|
|
86
102
|
|
|
87
103
|
/* ── BaseSpinner ──────────────────────────────────────────── */
|
|
88
104
|
@source inline("
|
|
89
|
-
|
|
105
|
+
w-4 h-4 w-6 h-6 w-8 h-8 w-12 h-12 border-2 border-4
|
|
106
|
+
border-gray-200 rounded-full animate-spin
|
|
107
|
+
border-t-{blue,emerald,sky,indigo,teal,purple,red,amber}-500
|
|
90
108
|
");
|
|
91
109
|
|
|
92
110
|
/* ── BaseRow ──────────────────────────────────────────────── */
|
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',
|
|
@@ -226,10 +243,19 @@ const safelist = [
|
|
|
226
243
|
'text-green-500',
|
|
227
244
|
|
|
228
245
|
// ── BaseSpinner ────────────────────────────────────────────
|
|
229
|
-
'
|
|
246
|
+
'w-4', 'h-4', 'w-6', 'h-6', 'w-8', 'h-8', 'w-12', 'h-12',
|
|
247
|
+
'border-2', 'border-4',
|
|
230
248
|
'border-gray-200',
|
|
231
|
-
'
|
|
249
|
+
'rounded-full',
|
|
232
250
|
'animate-spin',
|
|
251
|
+
'border-t-blue-500',
|
|
252
|
+
'border-t-emerald-500',
|
|
253
|
+
'border-t-sky-500',
|
|
254
|
+
'border-t-indigo-500',
|
|
255
|
+
'border-t-teal-500',
|
|
256
|
+
'border-t-purple-500',
|
|
257
|
+
'border-t-red-500',
|
|
258
|
+
'border-t-amber-500',
|
|
233
259
|
|
|
234
260
|
// ── BaseRow ────────────────────────────────────────────────
|
|
235
261
|
'block',
|