mgv-backoffice 1.25.0 → 1.27.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/package.json CHANGED
@@ -1,54 +1,54 @@
1
- {
2
- "name": "mgv-backoffice",
3
- "version": "1.25.0",
4
- "description": "Shared Vue 3 UI component library",
5
- "type": "module",
6
- "main": "./dist/ui-lib.umd.cjs",
7
- "module": "./dist/ui-lib.js",
8
- "types": "./dist/index.d.ts",
9
- "exports": {
10
- ".": {
11
- "types": "./dist/index.d.ts",
12
- "import": "./dist/ui-lib.js",
13
- "require": "./dist/ui-lib.umd.cjs"
14
- },
15
- "./dist/style.css": "./dist/ui-lib.css",
16
- "./tailwind.safelist": "./tailwind.safelist.js",
17
- "./tailwind.safelist.css": "./tailwind.safelist.css"
18
- },
19
- "files": [
20
- "dist",
21
- "src",
22
- "tailwind.safelist.js",
23
- "tailwind.safelist.css"
24
- ],
25
- "scripts": {
26
- "safelist": "node scripts/generate-safelist.mjs",
27
- "prebuild": "npm run safelist",
28
- "build": "vite build",
29
- "dev": "vite build --watch",
30
- "test": "vitest run",
31
- "test:watch": "vitest"
32
- },
33
- "peerDependencies": {
34
- "@heroicons/vue": "^2.0.0",
35
- "vue": "^3.5.0",
36
- "vue-router": "^5.0.0"
37
- },
38
- "devDependencies": {
39
- "@heroicons/vue": "^2.2.0",
40
- "@tailwindcss/vite": "^4.3.1",
41
- "@vitejs/plugin-vue": "^6.0.7",
42
- "@vue/language-core": "^3.3.5",
43
- "@vue/test-utils": "^2.4.11",
44
- "jsdom": "^29.1.1",
45
- "sass": "^1.101.0",
46
- "tailwindcss": "^4.3.1",
47
- "typescript": "^6.0.3",
48
- "vite": "^8.1.0",
49
- "vite-plugin-dts": "^5.0.3",
50
- "vitest": "^4.1.9",
51
- "vue": "^3.5.38",
52
- "vue-router": "^5.1.0"
53
- }
54
- }
1
+ {
2
+ "name": "mgv-backoffice",
3
+ "version": "1.27.0",
4
+ "description": "Shared Vue 3 UI component library",
5
+ "type": "module",
6
+ "main": "./dist/ui-lib.umd.cjs",
7
+ "module": "./dist/ui-lib.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "import": "./dist/ui-lib.js",
13
+ "require": "./dist/ui-lib.umd.cjs"
14
+ },
15
+ "./dist/style.css": "./dist/ui-lib.css",
16
+ "./tailwind.safelist": "./tailwind.safelist.js",
17
+ "./tailwind.safelist.css": "./tailwind.safelist.css"
18
+ },
19
+ "files": [
20
+ "dist",
21
+ "src",
22
+ "tailwind.safelist.js",
23
+ "tailwind.safelist.css"
24
+ ],
25
+ "scripts": {
26
+ "safelist": "node scripts/generate-safelist.mjs",
27
+ "prebuild": "npm run safelist",
28
+ "build": "vite build",
29
+ "dev": "vite build --watch",
30
+ "test": "vitest run",
31
+ "test:watch": "vitest"
32
+ },
33
+ "peerDependencies": {
34
+ "@heroicons/vue": "^2.0.0",
35
+ "vue": "^3.5.0",
36
+ "vue-router": "^5.0.0"
37
+ },
38
+ "devDependencies": {
39
+ "@heroicons/vue": "^2.2.0",
40
+ "@tailwindcss/vite": "^4.3.1",
41
+ "@vitejs/plugin-vue": "^6.0.7",
42
+ "@vue/language-core": "^3.3.5",
43
+ "@vue/test-utils": "^2.4.11",
44
+ "jsdom": "^29.1.1",
45
+ "sass": "^1.101.0",
46
+ "tailwindcss": "^4.3.1",
47
+ "typescript": "^6.0.3",
48
+ "vite": "^8.1.0",
49
+ "vite-plugin-dts": "^5.0.3",
50
+ "vitest": "^4.1.9",
51
+ "vue": "^3.5.38",
52
+ "vue-router": "^5.1.0"
53
+ }
54
+ }
@@ -18,11 +18,17 @@
18
18
  • toggle — fired when the user clicks the header. The parent
19
19
  flips its own collapsed state in response.
20
20
  -->
21
- <section class="rounded-xl border overflow-hidden transition-colors" :class="t.cardAlt">
21
+ <!--
22
+ NOTE: no `overflow-hidden` on the section — it would clip floating
23
+ menus (BaseDropdown) rendered near the bottom of the body. The child
24
+ corners are rounded explicitly instead so the card silhouette is
25
+ unchanged.
26
+ -->
27
+ <section class="rounded-xl border transition-colors" :class="t.cardAlt">
22
28
  <button
23
29
  type="button"
24
30
  class="w-full flex items-center justify-between p-4 text-left transition-colors cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-emerald-500"
25
- :class="headerClasses"
31
+ :class="[headerClasses, collapsed ? 'rounded-xl' : 'rounded-t-xl']"
26
32
  :aria-expanded="!collapsed"
27
33
  @click="emit('toggle')"
28
34
  >
@@ -50,7 +56,7 @@
50
56
  </button>
51
57
  <div
52
58
  v-show="!collapsed"
53
- class="p-6 border-t"
59
+ class="p-6 border-t rounded-b-xl"
54
60
  :class="[t.border, resolvedBodyClass]"
55
61
  >
56
62
  <slot />
@@ -25,7 +25,9 @@
25
25
  :aria-expanded="open"
26
26
  @click="toggle"
27
27
  >
28
- <span :class="selectedOption ? '' : 'text-slate-400 dark:text-slate-500'">
28
+ <!-- min-w-0 + truncate so long labels ellipsize inside narrow
29
+ (fixed-column) triggers instead of wrapping or widening them. -->
30
+ <span class="min-w-0 truncate" :class="selectedOption ? '' : 'text-slate-400 dark:text-slate-500'">
29
31
  {{ selectedOption ? selectedOption.label : placeholder }}
30
32
  </span>
31
33
  <ChevronDownIcon
@@ -39,7 +41,7 @@
39
41
  v-if="open"
40
42
  role="listbox"
41
43
  :aria-label="ariaLabel || placeholder"
42
- class="absolute z-20 mt-1 w-full max-h-60 overflow-auto rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-900 py-1 shadow-lg focus:outline-none"
44
+ class="absolute z-20 mt-1 w-full min-w-max max-w-xs max-h-60 overflow-auto rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-900 py-1 shadow-lg focus:outline-none"
43
45
  >
44
46
  <li
45
47
  v-for="opt in options"
@@ -0,0 +1,56 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { mount } from '@vue/test-utils'
3
+ import BaseStatBreakdown from './BaseStatBreakdown.vue'
4
+ import type { StatBreakdownItem } from '../types/statBreakdown'
5
+
6
+ const items: StatBreakdownItem[] = [
7
+ { label: 'Alpaca', value: 12.5 },
8
+ { label: 'Kraken', value: -3 },
9
+ { label: 'Binance', value: null },
10
+ ]
11
+
12
+ describe('BaseStatBreakdown', () => {
13
+ it('renders one row per item with label and formatted value', () => {
14
+ const wrapper = mount(BaseStatBreakdown, {
15
+ props: { items, currency: '$' },
16
+ })
17
+ const rows = wrapper.findAll('.flex')
18
+ expect(rows).toHaveLength(3)
19
+ expect(rows[0].text()).toContain('Alpaca')
20
+ expect(rows[0].text()).toContain('$12.50')
21
+ })
22
+
23
+ it('renders an em-dash for null values instead of a misleading 0', () => {
24
+ const wrapper = mount(BaseStatBreakdown, { props: { items } })
25
+ const rows = wrapper.findAll('.flex')
26
+ expect(rows[2].text()).toContain('—')
27
+ expect(rows[2].text()).not.toContain('0.00')
28
+ })
29
+
30
+ it('honours the decimals prop', () => {
31
+ const wrapper = mount(BaseStatBreakdown, {
32
+ props: { items: [{ label: 'BTC', value: 0.5 }], decimals: 8 },
33
+ })
34
+ expect(wrapper.text()).toContain('0.50000000')
35
+ })
36
+
37
+ it('prefixes a plus and colours by sign when signed', () => {
38
+ const wrapper = mount(BaseStatBreakdown, {
39
+ props: { items, currency: '$', signed: true },
40
+ })
41
+ const values = wrapper.findAll('.font-mono')
42
+ expect(values[0].text()).toBe('+$12.50')
43
+ expect(values[0].classes().join(' ')).toContain('text-emerald-600')
44
+ expect(values[1].text()).toBe('-$3.00')
45
+ expect(values[1].classes().join(' ')).toContain('text-red-600')
46
+ // Null rows stay neutral even in signed mode.
47
+ expect(values[2].classes().join(' ')).toContain('text-slate-500')
48
+ })
49
+
50
+ it('stays neutral-coloured when signed is off', () => {
51
+ const wrapper = mount(BaseStatBreakdown, {
52
+ props: { items: [{ label: 'Alpaca', value: 12.5 }] },
53
+ })
54
+ expect(wrapper.find('.font-mono').classes().join(' ')).toContain('text-slate-500')
55
+ })
56
+ })
@@ -0,0 +1,49 @@
1
+ <script setup lang="ts">
2
+ // Compact per-item breakdown meant to sit under a summary/stat card (pairs
3
+ // with EarningsCard). Each item renders on its own line — label left, value
4
+ // right in monospace. A null/undefined value (a source that is unconfigured,
5
+ // unreachable, or has no matching rows) shows a dash rather than a
6
+ // misleading 0. With `signed`, values gain an explicit "+" and are coloured
7
+ // green/red by sign.
8
+ import type { StatBreakdownItem } from '../types/statBreakdown'
9
+
10
+ const props = withDefaults(
11
+ defineProps<{
12
+ items: StatBreakdownItem[]
13
+ currency?: string
14
+ decimals?: number
15
+ signed?: boolean
16
+ }>(),
17
+ { currency: '', decimals: 2, signed: false },
18
+ )
19
+
20
+ function fmt(v: number | null | undefined): string {
21
+ if (v === null || v === undefined) return '—'
22
+ // Sign sits before the currency symbol ("-$3.00", "+$5.00" — same shape
23
+ // as fmtUsd); the plus only appears in signed mode.
24
+ const sign = v < 0 ? '-' : props.signed ? '+' : ''
25
+ return `${sign}${props.currency}${Math.abs(v).toFixed(props.decimals)}`
26
+ }
27
+
28
+ function color(v: number | null | undefined): string {
29
+ if (!props.signed || v === null || v === undefined) {
30
+ return 'text-slate-500 dark:text-slate-400'
31
+ }
32
+ return v >= 0 ? 'text-emerald-600 dark:text-emerald-400' : 'text-red-600 dark:text-red-400'
33
+ }
34
+ </script>
35
+
36
+ <template>
37
+ <div class="mt-1.5 space-y-0.5 px-1">
38
+ <div
39
+ v-for="item in items"
40
+ :key="item.label"
41
+ class="flex items-center justify-between text-[11px]"
42
+ >
43
+ <span class="text-slate-400 dark:text-slate-500">{{ item.label }}</span>
44
+ <span class="font-mono tabular-nums font-medium" :class="color(item.value)">
45
+ {{ fmt(item.value) }}
46
+ </span>
47
+ </div>
48
+ </div>
49
+ </template>
package/src/index.ts CHANGED
@@ -41,6 +41,7 @@ export { default as BaseDropdown } from './components/BaseDropdown.vue'
41
41
  export { default as BaseSegmentedControl } from './components/BaseSegmentedControl.vue'
42
42
  export { default as BaseTable } from './components/BaseTable.vue'
43
43
  export { default as BaseSpecFields } from './components/BaseSpecFields.vue'
44
+ export { default as BaseStatBreakdown } from './components/BaseStatBreakdown.vue'
44
45
 
45
46
  // Composables
46
47
  export { useTheme, initTheme } from './composables/useTheme'
@@ -77,6 +78,7 @@ export type { SegmentedOption } from './types/segmented'
77
78
  export type { DropdownOption } from './types/dropdown'
78
79
  export type { TableColumn } from './types/table'
79
80
  export type { SpecField, SpecFieldType, SpecFieldValue } from './types/specField'
81
+ export type { StatBreakdownItem } from './types/statBreakdown'
80
82
 
81
83
  // Utils
82
84
  export { getBaseColor, getBaseColorOf } from './utils/util'
@@ -95,6 +97,7 @@ export {
95
97
  fmtPrice,
96
98
  fmtPct,
97
99
  fmtUsd,
100
+ fmtDuration,
98
101
  } from './utils/format'
99
102
  export { computePnL } from './utils/pnl'
100
103
  export type { PnL, PnLInputs } from './utils/pnl'
package/src/style.css CHANGED
@@ -1 +1,10 @@
1
1
  @import "tailwindcss";
2
+
3
+ /* Compile `dark:` variants against the `.dark` class that useTheme()/initTheme()
4
+ toggle on <html> — NOT the OS-level prefers-color-scheme media query
5
+ (Tailwind v4's default). Both consumers (TradeAutomation, WireMate) switch
6
+ themes with the class toggle, so media-gated dark rules leak the dark skin
7
+ into the light theme for users whose OS prefers dark.
8
+ Use a specificity-bearing selector (:is, not :where) so dark: utilities win
9
+ over plain base utilities like bg-slate-50 on a cascade tie. */
10
+ @custom-variant dark (&:is(.dark, .dark *));
@@ -0,0 +1,6 @@
1
+ export interface StatBreakdownItem {
2
+ /** Row label rendered on the left (e.g. a venue or category name). */
3
+ label: string
4
+ /** Numeric value; null/undefined renders as an em-dash rather than 0. */
5
+ value: number | null | undefined
6
+ }
@@ -7,6 +7,7 @@ import {
7
7
  fmtPrice,
8
8
  fmtPct,
9
9
  fmtUsd,
10
+ fmtDuration,
10
11
  } from './format'
11
12
 
12
13
  const EM_DASH = '—'
@@ -127,3 +128,37 @@ describe('fmtUsd', () => {
127
128
  expect(fmtUsd(0)).toBe('+$0.00')
128
129
  })
129
130
  })
131
+
132
+ describe('fmtDuration', () => {
133
+ it('renders an em-dash for null/undefined/non-finite input', () => {
134
+ expect(fmtDuration(null)).toBe(EM_DASH)
135
+ expect(fmtDuration(undefined)).toBe(EM_DASH)
136
+ expect(fmtDuration(NaN)).toBe(EM_DASH)
137
+ expect(fmtDuration(Infinity)).toBe(EM_DASH)
138
+ })
139
+
140
+ it('drops zero-value leading units', () => {
141
+ expect(fmtDuration(12 * 60_000)).toBe('12m')
142
+ expect(fmtDuration((5 * 60 + 12) * 60_000)).toBe('5h 12m')
143
+ expect(fmtDuration((3 * 1440 + 5 * 60 + 12) * 60_000)).toBe('3d 5h')
144
+ })
145
+
146
+ it('caps the rendered units at maxUnits', () => {
147
+ const ms = (2 * 1440 + 5 * 60 + 13) * 60_000
148
+ expect(fmtDuration(ms, 3)).toBe('2d 5h 13m')
149
+ expect(fmtDuration(ms, 2)).toBe('2d 5h')
150
+ expect(fmtDuration(ms, 1)).toBe('2d')
151
+ })
152
+
153
+ it('keeps the hours unit when days are present even if hours are zero', () => {
154
+ expect(fmtDuration(2 * 1440 * 60_000, 3)).toBe('2d 0h 0m')
155
+ })
156
+
157
+ it('clamps negative durations to zero', () => {
158
+ expect(fmtDuration(-5000)).toBe('0m')
159
+ })
160
+
161
+ it('renders sub-minute durations as 0m', () => {
162
+ expect(fmtDuration(59_000)).toBe('0m')
163
+ })
164
+ })
@@ -87,3 +87,22 @@ export function fmtUsd(v: number): string {
87
87
  const sign = v < 0 ? '-' : '+'
88
88
  return `${sign}$${Math.abs(v).toFixed(2)}`
89
89
  }
90
+
91
+ /**
92
+ * Format a millisecond duration as compact day/hour/minute units, e.g.
93
+ * "3d 5h", "5h 12m" or "12m". Zero-value leading units are dropped;
94
+ * `maxUnits` caps how many units render (2 → "3d 5h", 3 → "3d 5h 12m").
95
+ * Renders an em-dash for null/undefined/non-finite input.
96
+ */
97
+ export function fmtDuration(ms: number | null | undefined, maxUnits = 2): string {
98
+ if (ms === null || ms === undefined || !isFinite(ms)) return EM_DASH
99
+ const totalMins = Math.floor(Math.max(0, ms) / 60_000)
100
+ const days = Math.floor(totalMins / 1440)
101
+ const hours = Math.floor((totalMins % 1440) / 60)
102
+ const mins = totalMins % 60
103
+ const parts: string[] = []
104
+ if (days > 0) parts.push(`${days}d`)
105
+ if (days > 0 || hours > 0) parts.push(`${hours}h`)
106
+ parts.push(`${mins}m`)
107
+ return parts.slice(0, maxUnits).join(' ')
108
+ }