mgv-backoffice 1.11.0 → 1.16.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.
@@ -0,0 +1,135 @@
1
+ <template>
2
+ <!--
3
+ Copy-to-clipboard icon button with transient "copied" feedback.
4
+ Click → writes `text` to the clipboard → swaps the clipboard icon
5
+ for a checkmark for `resetMs` → reverts. Emits `copied` / `error`
6
+ so the parent can surface its own toast (the button itself stays
7
+ silent — the icon swap is the built-in affordance).
8
+
9
+ Variants:
10
+ • ghost — borderless `p-1` icon, emerald hover tint. The
11
+ affordance used next to inline IDs / code headers.
12
+ • bordered — `w-9 h-9` boxed button that turns emerald while
13
+ "copied". Pairs with a read-only input in a form row.
14
+
15
+ Copy strategy: the async Clipboard API where available, falling
16
+ back to a hidden-textarea `execCommand('copy')` so it still works
17
+ on insecure origins / older browsers.
18
+ -->
19
+ <button
20
+ type="button"
21
+ :title="title"
22
+ :aria-label="ariaLabel"
23
+ :class="buttonClass"
24
+ @click="handleCopy"
25
+ >
26
+ <ClipboardDocumentCheckIcon v-if="copied" :class="iconClass" aria-hidden="true" />
27
+ <ClipboardDocumentIcon v-else :class="iconClass" aria-hidden="true" />
28
+ </button>
29
+ </template>
30
+
31
+ <script setup lang="ts">
32
+ import { ref, computed, onUnmounted } from 'vue'
33
+ import { ClipboardDocumentIcon, ClipboardDocumentCheckIcon } from '@heroicons/vue/24/outline'
34
+ import { useTheme } from '../composables/useTheme'
35
+
36
+ type CopyVariant = 'ghost' | 'bordered'
37
+
38
+ const props = withDefaults(
39
+ defineProps<{
40
+ /** The text written to the clipboard on click. */
41
+ text: string
42
+ /** Used in the tooltip / aria-label, e.g. label="Stub ID" → "Copy Stub ID". */
43
+ label?: string
44
+ /** Visual style. See the component comment. */
45
+ variant?: CopyVariant
46
+ /** How long the checkmark stays before reverting (ms). */
47
+ resetMs?: number
48
+ /** Icon size class. */
49
+ iconClass?: string
50
+ }>(),
51
+ {
52
+ label: '',
53
+ variant: 'ghost',
54
+ resetMs: 1500,
55
+ iconClass: 'w-4 h-4',
56
+ },
57
+ )
58
+
59
+ const emit = defineEmits<{
60
+ copied: []
61
+ error: [error: unknown]
62
+ }>()
63
+
64
+ const { isDark } = useTheme()
65
+
66
+ const copied = ref(false)
67
+ let timer: ReturnType<typeof setTimeout> | null = null
68
+
69
+ const title = computed(() => {
70
+ if (copied.value) return 'Copied!'
71
+ return props.label ? `Copy ${props.label}` : 'Copy'
72
+ })
73
+ const ariaLabel = computed(() => (props.label ? `Copy ${props.label}` : 'Copy'))
74
+
75
+ const buttonClass = computed(() => {
76
+ if (props.variant === 'bordered') {
77
+ const base =
78
+ 'inline-flex items-center justify-center w-9 h-9 rounded-lg border transition-colors cursor-pointer shrink-0'
79
+ const state = copied.value
80
+ ? isDark.value
81
+ ? 'border-emerald-500/40 bg-emerald-500/10 text-emerald-400'
82
+ : 'border-emerald-300 bg-emerald-50 text-emerald-600'
83
+ : isDark.value
84
+ ? 'border-gray-600 text-gray-400 hover:text-gray-200 hover:bg-gray-800'
85
+ : 'border-gray-300 text-gray-400 hover:text-gray-600 hover:bg-gray-50'
86
+ return `${base} ${state}`
87
+ }
88
+ // ghost
89
+ const base = 'p-1 rounded transition-colors cursor-pointer'
90
+ const state = isDark.value
91
+ ? 'text-gray-400 hover:text-emerald-300 hover:bg-emerald-500/10'
92
+ : 'text-gray-500 hover:text-emerald-600 hover:bg-emerald-50'
93
+ return `${base} ${state}`
94
+ })
95
+
96
+ async function writeToClipboard(text: string): Promise<void> {
97
+ // Prefer the async Clipboard API, but fall back to execCommand if it
98
+ // is unavailable or throws (e.g. insecure origin / denied permission).
99
+ try {
100
+ if (navigator.clipboard?.writeText) {
101
+ await navigator.clipboard.writeText(text)
102
+ return
103
+ }
104
+ } catch {
105
+ // fall through to the textarea fallback
106
+ }
107
+ const ta = document.createElement('textarea')
108
+ ta.value = text
109
+ ta.style.position = 'fixed'
110
+ ta.style.opacity = '0'
111
+ document.body.appendChild(ta)
112
+ ta.select()
113
+ const ok = document.execCommand('copy')
114
+ document.body.removeChild(ta)
115
+ if (!ok) throw new Error('Copy command was rejected')
116
+ }
117
+
118
+ async function handleCopy() {
119
+ try {
120
+ await writeToClipboard(props.text)
121
+ copied.value = true
122
+ if (timer) clearTimeout(timer)
123
+ timer = setTimeout(() => {
124
+ copied.value = false
125
+ }, props.resetMs)
126
+ emit('copied')
127
+ } catch (e) {
128
+ emit('error', e)
129
+ }
130
+ }
131
+
132
+ onUnmounted(() => {
133
+ if (timer) clearTimeout(timer)
134
+ })
135
+ </script>
@@ -21,7 +21,7 @@
21
21
 
22
22
  <!-- Loading -->
23
23
  <div v-if="loading" class="mt-4 flex justify-center py-6">
24
- <BaseSpinner />
24
+ <BaseSpinner color="emerald" />
25
25
  </div>
26
26
 
27
27
  <!-- Error -->
@@ -23,7 +23,45 @@
23
23
  :aria-labelledby="titleId"
24
24
  @click="onBackdrop"
25
25
  >
26
+ <!--
27
+ Scrollable layout — for large content modals. A flex column
28
+ capped at 90vh: header and footer stay fixed (`shrink-0`) while
29
+ the body scrolls (`overflow-y-auto flex-1`). Supports a subtitle
30
+ and a `header-actions` slot (e.g. a close button) on the right.
31
+ -->
26
32
  <div
33
+ v-if="scrollable"
34
+ class="relative w-full mx-4 rounded-lg shadow-xl max-h-[90vh] flex flex-col"
35
+ :class="[maxWidthClass, shellClass]"
36
+ @click.stop
37
+ >
38
+ <div class="flex items-start justify-between gap-4 p-6 border-b shrink-0" :class="t.border">
39
+ <div class="flex items-start gap-4 min-w-0">
40
+ <slot name="icon" />
41
+ <div class="flex-1 min-w-0">
42
+ <h3 :id="titleId" class="text-lg font-medium" :class="t.primaryText">
43
+ {{ title }}
44
+ </h3>
45
+ <p v-if="subtitle" class="text-sm mt-1 break-all" :class="t.mutedText">
46
+ {{ subtitle }}
47
+ </p>
48
+ </div>
49
+ </div>
50
+ <slot name="header-actions" />
51
+ </div>
52
+
53
+ <div class="p-6 overflow-y-auto flex-1">
54
+ <slot />
55
+ </div>
56
+
57
+ <div v-if="$slots.footer" class="flex gap-3 justify-end p-6 border-t shrink-0" :class="t.border">
58
+ <slot name="footer" />
59
+ </div>
60
+ </div>
61
+
62
+ <!-- Compact layout (default) — small centered dialog. -->
63
+ <div
64
+ v-else
27
65
  class="relative w-full mx-4 rounded-lg shadow-xl"
28
66
  :class="[maxWidthClass, shellClass]"
29
67
  @click.stop
@@ -78,11 +116,25 @@ interface Props {
78
116
  * `backdrop` / handle their own Escape.
79
117
  */
80
118
  manualClose?: boolean
119
+ /**
120
+ * Switches to the scrollable layout — a flex column capped at 90vh
121
+ * with a fixed header/footer and a scrolling body. Use for large
122
+ * content modals (request detail, import preview, …) rather than the
123
+ * compact confirm-style default.
124
+ */
125
+ scrollable?: boolean
126
+ /**
127
+ * Optional muted line under the title. Only rendered in the
128
+ * scrollable layout.
129
+ */
130
+ subtitle?: string
81
131
  }
82
132
 
83
133
  const props = withDefaults(defineProps<Props>(), {
84
134
  maxWidthClass: 'max-w-md',
85
135
  manualClose: false,
136
+ scrollable: false,
137
+ subtitle: '',
86
138
  })
87
139
 
88
140
  const emit = defineEmits<{
@@ -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
  }
package/src/index.ts CHANGED
@@ -29,6 +29,7 @@ export { default as BaseAppLayout } from './components/BaseAppLayout.vue'
29
29
  export { default as BasePageHeader } from './components/BasePageHeader.vue'
30
30
  export { default as BaseToolbarButton } from './components/BaseToolbarButton.vue'
31
31
  export { default as BaseActionButton } from './components/BaseActionButton.vue'
32
+ export { default as BaseCopyButton } from './components/BaseCopyButton.vue'
32
33
 
33
34
  // Composables
34
35
  export { useTheme } from './composables/useTheme'
@@ -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 ────────────────────────────────────────────── */
@@ -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',