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.
@@ -1,3 +1,76 @@
1
- <template>
2
- <div class="w-4 h-4 border-2 border-gray-200 border-t-blue-500 rounded-full animate-spin"></div>
3
- </template>
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
- return props.currency + props.amount.toLocaleString('en-US', {
24
- minimumFractionDigits: props.decimals,
25
- maximumFractionDigits: props.decimals,
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 class="relative rounded-xl border-2 border-dashed border-orange-300 bg-white p-6 dark:border-orange-700 dark:bg-slate-800">
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 bg-orange-100 px-3 py-1 text-xs font-bold tracking-wide text-orange-500 dark:bg-orange-900/40 dark:text-orange-400"
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 text-gray-400 dark:text-slate-300">
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 text-orange-400 dark:text-orange-400">
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 class="mt-6 flex h-12 w-12 items-center justify-center rounded-xl bg-orange-50 dark:bg-orange-900/30">
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 text-orange-400"
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"
@@ -4,6 +4,10 @@ export enum BaseButtonEnum {
4
4
  WHITE = "WHITE",
5
5
  DARK = "DARK",
6
6
  GREEN = "GREEN",
7
+ EMERALD = "EMERALD",
7
8
  YELLOW = "YELLOW",
8
9
  PURPLE = "PURPLE",
10
+ SKY = "SKY",
11
+ GRAY = "GRAY",
12
+ AMBER = "AMBER",
9
13
  }
@@ -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
- if (!isFinite(buy) || !isFinite(last) || !isFinite(qty) || buy <= 0) {
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 {
@@ -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
- border-2 border-gray-200 border-t-blue-500 animate-spin
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 ──────────────────────────────────────────────── */
@@ -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
- 'border-2',
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
- 'border-t-blue-500',
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',