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/dist/components/BaseStatBreakdown.vue.d.ts +14 -0
- package/dist/index.d.ts +3 -1
- package/dist/types/statBreakdown.d.ts +6 -0
- package/dist/ui-lib.css +1 -1
- package/dist/ui-lib.js +70 -41
- package/dist/ui-lib.umd.cjs +1 -1
- package/dist/utils/format.d.ts +7 -0
- package/package.json +54 -54
- package/src/components/BaseCollapsibleSection.vue +9 -3
- package/src/components/BaseDropdown.vue +4 -2
- package/src/components/BaseStatBreakdown.test.ts +56 -0
- package/src/components/BaseStatBreakdown.vue +49 -0
- package/src/index.ts +3 -0
- package/src/style.css +9 -0
- package/src/types/statBreakdown.ts +6 -0
- package/src/utils/format.test.ts +35 -0
- package/src/utils/format.ts +19 -0
package/package.json
CHANGED
|
@@ -1,54 +1,54 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "mgv-backoffice",
|
|
3
|
-
"version": "1.
|
|
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
|
-
|
|
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
|
-
|
|
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 *));
|
package/src/utils/format.test.ts
CHANGED
|
@@ -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
|
+
})
|
package/src/utils/format.ts
CHANGED
|
@@ -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
|
+
}
|