mgv-backoffice 1.4.0 → 1.8.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 +146 -0
- package/dist/src/components/BaseActionButton.vue.d.ts +41 -0
- package/dist/src/components/BasePageHeader.vue.d.ts +31 -0
- package/dist/src/components/BaseToolbarButton.vue.d.ts +37 -0
- package/dist/src/components/EarningsCard.vue.d.ts +2 -0
- package/dist/src/index.d.ts +7 -0
- package/dist/src/utils/format.d.ts +40 -0
- package/dist/src/utils/pnl.d.ts +24 -0
- package/dist/src/utils/sanitizeHtml.d.ts +31 -0
- package/dist/ui-lib.css +1 -1
- package/dist/ui-lib.js +338 -19
- package/dist/ui-lib.umd.cjs +1 -1
- package/package.json +1 -1
- package/src/components/BaseActionButton.vue +142 -0
- package/src/components/BasePageHeader.vue +97 -0
- package/src/components/BaseToolbarButton.vue +79 -0
- package/src/components/EarningsCard.vue +10 -8
- package/src/index.ts +15 -0
- package/src/utils/format.ts +89 -0
- package/src/utils/pnl.ts +40 -0
- package/src/utils/sanitizeHtml.ts +116 -0
|
@@ -7,6 +7,7 @@ interface Props {
|
|
|
7
7
|
subtitle?: string
|
|
8
8
|
badge?: string
|
|
9
9
|
currency?: string
|
|
10
|
+
decimals?: number
|
|
10
11
|
}
|
|
11
12
|
|
|
12
13
|
const props = withDefaults(defineProps<Props>(), {
|
|
@@ -15,22 +16,23 @@ const props = withDefaults(defineProps<Props>(), {
|
|
|
15
16
|
subtitle: 'Lifetime commission',
|
|
16
17
|
badge: '',
|
|
17
18
|
currency: '$',
|
|
19
|
+
decimals: 2,
|
|
18
20
|
})
|
|
19
21
|
|
|
20
22
|
const formattedAmount = computed(() => {
|
|
21
23
|
return props.currency + props.amount.toLocaleString('en-US', {
|
|
22
|
-
minimumFractionDigits:
|
|
23
|
-
maximumFractionDigits:
|
|
24
|
+
minimumFractionDigits: props.decimals,
|
|
25
|
+
maximumFractionDigits: props.decimals,
|
|
24
26
|
})
|
|
25
27
|
})
|
|
26
28
|
</script>
|
|
27
29
|
|
|
28
30
|
<template>
|
|
29
|
-
<div class="relative rounded-xl border-2 border-dashed border-orange-300 bg-white p-6">
|
|
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">
|
|
30
32
|
<!-- Badge -->
|
|
31
33
|
<div
|
|
32
34
|
v-if="badge"
|
|
33
|
-
class="absolute right-4 top-4 rounded-md bg-orange-100 px-3 py-1 text-xs font-bold tracking-wide text-orange-500"
|
|
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"
|
|
34
36
|
>
|
|
35
37
|
{{ badge }}
|
|
36
38
|
</div>
|
|
@@ -39,23 +41,23 @@ const formattedAmount = computed(() => {
|
|
|
39
41
|
<div class="flex items-start justify-between">
|
|
40
42
|
<div>
|
|
41
43
|
<!-- Title -->
|
|
42
|
-
<p class="text-sm font-bold tracking-wide text-gray-800">
|
|
44
|
+
<p class="text-sm font-bold tracking-wide text-gray-800 dark:text-slate-200">
|
|
43
45
|
{{ title }}
|
|
44
46
|
</p>
|
|
45
47
|
|
|
46
48
|
<!-- Amount -->
|
|
47
|
-
<p class="mt-1 text-4xl font-bold text-gray-400">
|
|
49
|
+
<p class="mt-1 text-4xl font-bold text-gray-400 dark:text-slate-300">
|
|
48
50
|
{{ formattedAmount }}
|
|
49
51
|
</p>
|
|
50
52
|
|
|
51
53
|
<!-- Subtitle -->
|
|
52
|
-
<p class="mt-1 text-sm font-medium text-orange-400">
|
|
54
|
+
<p class="mt-1 text-sm font-medium text-orange-400 dark:text-orange-400">
|
|
53
55
|
{{ subtitle }}
|
|
54
56
|
</p>
|
|
55
57
|
</div>
|
|
56
58
|
|
|
57
59
|
<!-- Chart icon -->
|
|
58
|
-
<div class="mt-6 flex h-12 w-12 items-center justify-center rounded-xl bg-orange-50">
|
|
60
|
+
<div class="mt-6 flex h-12 w-12 items-center justify-center rounded-xl bg-orange-50 dark:bg-orange-900/30">
|
|
59
61
|
<svg
|
|
60
62
|
class="h-6 w-6 text-orange-400"
|
|
61
63
|
viewBox="0 0 24 24"
|
package/src/index.ts
CHANGED
|
@@ -26,6 +26,9 @@ export { default as BaseNotFoundPage } from './components/BaseNotFoundPage.vue'
|
|
|
26
26
|
export { default as BaseSidebar } from './components/BaseSidebar.vue'
|
|
27
27
|
export { default as BaseEntityPickerModal } from './components/BaseEntityPickerModal.vue'
|
|
28
28
|
export { default as BaseAppLayout } from './components/BaseAppLayout.vue'
|
|
29
|
+
export { default as BasePageHeader } from './components/BasePageHeader.vue'
|
|
30
|
+
export { default as BaseToolbarButton } from './components/BaseToolbarButton.vue'
|
|
31
|
+
export { default as BaseActionButton } from './components/BaseActionButton.vue'
|
|
29
32
|
|
|
30
33
|
// Composables
|
|
31
34
|
export { useTheme } from './composables/useTheme'
|
|
@@ -62,3 +65,15 @@ export {
|
|
|
62
65
|
statusBadgeSolid,
|
|
63
66
|
statusBadgeTinted,
|
|
64
67
|
} from './utils/httpColors'
|
|
68
|
+
export { sanitizeHtml, isSafeHref } from './utils/sanitizeHtml'
|
|
69
|
+
export {
|
|
70
|
+
fmtNumber,
|
|
71
|
+
fmtDate,
|
|
72
|
+
fmtDateTime,
|
|
73
|
+
fmtDateShort,
|
|
74
|
+
fmtPrice,
|
|
75
|
+
fmtPct,
|
|
76
|
+
fmtUsd,
|
|
77
|
+
} from './utils/format'
|
|
78
|
+
export { computePnL } from './utils/pnl'
|
|
79
|
+
export type { PnL, PnLInputs } from './utils/pnl'
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Locale-aware display formatters shared across back-office tables, logs and
|
|
3
|
+
* charts. All are pure and dependency-free; they take the "missing value"
|
|
4
|
+
* branch seriously so callers can hand them raw API values (which may be
|
|
5
|
+
* null/strings) without pre-sanitising.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const EM_DASH = '—'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Format a number with a fixed number of fraction digits, rendering an
|
|
12
|
+
* em-dash for null/undefined/non-finite input. Accepts numeric strings so
|
|
13
|
+
* BigDecimal-as-string API payloads pass through unchanged.
|
|
14
|
+
*/
|
|
15
|
+
export function fmtNumber(n: number | string | null | undefined, digits = 4): string {
|
|
16
|
+
if (n === null || n === undefined) return EM_DASH
|
|
17
|
+
const v = typeof n === 'string' ? Number(n) : n
|
|
18
|
+
if (!isFinite(v)) return EM_DASH
|
|
19
|
+
return v.toLocaleString(undefined, {
|
|
20
|
+
minimumFractionDigits: digits,
|
|
21
|
+
maximumFractionDigits: digits,
|
|
22
|
+
})
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Format an ISO date string or epoch value as a locale date-time, falling
|
|
27
|
+
* back to the raw value on parse failure and an em-dash on empty input.
|
|
28
|
+
* Accepts numbers so epoch-millis timestamps pass through unchanged.
|
|
29
|
+
*/
|
|
30
|
+
export function fmtDate(s: string | number | null | undefined): string {
|
|
31
|
+
if (s === null || s === undefined || s === '') return EM_DASH
|
|
32
|
+
try {
|
|
33
|
+
return new Date(s).toLocaleString()
|
|
34
|
+
} catch {
|
|
35
|
+
return String(s)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Format an epoch-millis timestamp as a compact "Mon D, HH:MM" label —
|
|
41
|
+
* the form used for chart axes and tooltips.
|
|
42
|
+
*/
|
|
43
|
+
export function fmtDateTime(ms: number): string {
|
|
44
|
+
return new Date(ms).toLocaleString(undefined, {
|
|
45
|
+
month: 'short',
|
|
46
|
+
day: 'numeric',
|
|
47
|
+
hour: '2-digit',
|
|
48
|
+
minute: '2-digit',
|
|
49
|
+
})
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Format an epoch-millis timestamp as a short "Mon D" calendar label.
|
|
54
|
+
*/
|
|
55
|
+
export function fmtDateShort(ms: number): string {
|
|
56
|
+
return new Date(ms).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Format a price with precision that scales to magnitude: penny stocks and
|
|
61
|
+
* sub-cent crypto get more decimals so the value never collapses to "0.00".
|
|
62
|
+
*/
|
|
63
|
+
export function fmtPrice(n: number): string {
|
|
64
|
+
if (!isFinite(n) || n === 0) return n.toFixed(2)
|
|
65
|
+
const abs = Math.abs(n)
|
|
66
|
+
let digits = 2
|
|
67
|
+
if (abs < 0.0001) digits = 8
|
|
68
|
+
else if (abs < 0.01) digits = 6
|
|
69
|
+
else if (abs < 1) digits = 4
|
|
70
|
+
else if (abs < 100) digits = 3
|
|
71
|
+
return n.toLocaleString(undefined, {
|
|
72
|
+
minimumFractionDigits: digits,
|
|
73
|
+
maximumFractionDigits: digits,
|
|
74
|
+
})
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Format a percentage with an explicit sign (e.g. "+2.50%", "-1.00%").
|
|
79
|
+
*/
|
|
80
|
+
export function fmtPct(n: number, digits = 2): string {
|
|
81
|
+
return `${n >= 0 ? '+' : ''}${n.toFixed(digits)}%`
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Format a signed USD amount with an explicit leading sign (e.g. "+$5.00").
|
|
86
|
+
*/
|
|
87
|
+
export function fmtUsd(v: number): string {
|
|
88
|
+
return `${v >= 0 ? '+' : ''}$${v.toFixed(2)}`
|
|
89
|
+
}
|
package/src/utils/pnl.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unrealised profit-and-loss for an open position, expressed in both
|
|
3
|
+
* absolute currency and percentage terms.
|
|
4
|
+
*/
|
|
5
|
+
export interface PnL {
|
|
6
|
+
pnlUsd: number | null
|
|
7
|
+
pnlPct: number | null
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Minimal position shape needed to compute mark-to-market PnL. */
|
|
11
|
+
export interface PnLInputs {
|
|
12
|
+
buyPrice: number | string | null | undefined
|
|
13
|
+
lastPrice: number | string | null | undefined
|
|
14
|
+
filledQty: number | string | null | undefined
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Compute unrealised PnL from the latest observed market price. Returns
|
|
19
|
+
* `{ null, null }` when any input is missing or non-finite (a freshly opened
|
|
20
|
+
* position not yet priced, a corrupt row, etc.) so the UI can render a
|
|
21
|
+
* placeholder instead of a bogus figure.
|
|
22
|
+
*
|
|
23
|
+
* The explicit null guard matters: `Number(null) === 0`, which would
|
|
24
|
+
* otherwise silently produce PnL = -buyPrice * qty when a price is absent.
|
|
25
|
+
*/
|
|
26
|
+
export function computePnL(row: PnLInputs): PnL {
|
|
27
|
+
if (row.buyPrice == null || row.lastPrice == null || row.filledQty == null) {
|
|
28
|
+
return { pnlUsd: null, pnlPct: null }
|
|
29
|
+
}
|
|
30
|
+
const buy = Number(row.buyPrice)
|
|
31
|
+
const last = Number(row.lastPrice)
|
|
32
|
+
const qty = Number(row.filledQty)
|
|
33
|
+
if (!isFinite(buy) || !isFinite(last) || !isFinite(qty) || buy <= 0) {
|
|
34
|
+
return { pnlUsd: null, pnlPct: null }
|
|
35
|
+
}
|
|
36
|
+
return {
|
|
37
|
+
pnlUsd: (last - buy) * qty,
|
|
38
|
+
pnlPct: ((last - buy) / buy) * 100,
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Allow-list HTML sanitizer for strings bound into `v-html`.
|
|
3
|
+
*
|
|
4
|
+
* Use whenever rich HTML from any source (backend payloads, user input,
|
|
5
|
+
* imported content) is rendered via `v-html`. Even "trusted" sources
|
|
6
|
+
* should be sanitised belt-and-suspenders so a future change can't
|
|
7
|
+
* smuggle script tags, inline event handlers, or `javascript:` hrefs
|
|
8
|
+
* into the page.
|
|
9
|
+
*
|
|
10
|
+
* Implementation:
|
|
11
|
+
* 1. Parse with `DOMParser` into an off-document tree.
|
|
12
|
+
* 2. Walk the tree once, replacing any disallowed element with its
|
|
13
|
+
* `textContent` (the readable text survives, the wrapper is gone).
|
|
14
|
+
* 3. On every element, strip every attribute except the per-tag
|
|
15
|
+
* allow-list below.
|
|
16
|
+
* 4. Re-validate `a[href]` against an explicit scheme whitelist —
|
|
17
|
+
* `javascript:`, `data:`, `vbscript:`, and `file:` are rejected.
|
|
18
|
+
* 5. Force `rel="noopener noreferrer" target="_blank"` on every
|
|
19
|
+
* surviving anchor so external links can't reach back through
|
|
20
|
+
* `window.opener`.
|
|
21
|
+
*
|
|
22
|
+
* Returns the cleaned HTML string, ready for `v-html`. Browser-only —
|
|
23
|
+
* relies on `DOMParser`.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const ALLOWED_TAGS: ReadonlySet<string> = new Set([
|
|
27
|
+
'A', 'B', 'STRONG', 'I', 'EM', 'CODE', 'PRE', 'BR',
|
|
28
|
+
'P', 'UL', 'OL', 'LI', 'SPAN', 'DIV',
|
|
29
|
+
])
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Tags whose contents are also dropped (not unwrapped as text). For
|
|
33
|
+
* most disallowed elements we keep the inner text so the user-visible
|
|
34
|
+
* copy survives even when the wrapper is stripped, but for
|
|
35
|
+
* code-bearing elements like `<script>` and `<style>` the inner text
|
|
36
|
+
* is the payload itself — leaving it as a visible text node would
|
|
37
|
+
* splat raw JS / CSS source into the rendered output. Drop the
|
|
38
|
+
* element entirely instead.
|
|
39
|
+
*/
|
|
40
|
+
const STRIP_WITH_CONTENT: ReadonlySet<string> = new Set([
|
|
41
|
+
'SCRIPT', 'STYLE', 'IFRAME', 'OBJECT', 'EMBED', 'NOSCRIPT', 'TEMPLATE',
|
|
42
|
+
])
|
|
43
|
+
|
|
44
|
+
const ALLOWED_ATTRS_BY_TAG: Readonly<Record<string, ReadonlySet<string>>> = {
|
|
45
|
+
A: new Set(['href', 'title']),
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Returns true when the supplied href is safe to render as-is. Blocks
|
|
50
|
+
* every scheme that has historically been a vector for XSS or local-
|
|
51
|
+
* file leakage.
|
|
52
|
+
*/
|
|
53
|
+
export function isSafeHref(value: string): boolean {
|
|
54
|
+
const trimmed = value.trim().toLowerCase()
|
|
55
|
+
return (
|
|
56
|
+
trimmed.startsWith('http://')
|
|
57
|
+
|| trimmed.startsWith('https://')
|
|
58
|
+
|| trimmed.startsWith('mailto:')
|
|
59
|
+
|| trimmed.startsWith('tel:')
|
|
60
|
+
|| trimmed.startsWith('/')
|
|
61
|
+
|| trimmed.startsWith('#')
|
|
62
|
+
|| trimmed === ''
|
|
63
|
+
)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function sanitizeNode(node: Node): void {
|
|
67
|
+
if (node.nodeType !== Node.ELEMENT_NODE) return
|
|
68
|
+
const el = node as Element
|
|
69
|
+
const tag = el.tagName.toUpperCase()
|
|
70
|
+
// Disallowed element: replace with its textContent so the text the
|
|
71
|
+
// user is meant to read survives, but the wrapper is gone. For
|
|
72
|
+
// code-bearing elements (`<script>`, `<style>`, etc.) we drop the
|
|
73
|
+
// contents too so the JS/CSS source itself doesn't render as
|
|
74
|
+
// visible text.
|
|
75
|
+
if (!ALLOWED_TAGS.has(tag)) {
|
|
76
|
+
if (STRIP_WITH_CONTENT.has(tag)) {
|
|
77
|
+
el.remove()
|
|
78
|
+
} else {
|
|
79
|
+
el.replaceWith(document.createTextNode(el.textContent ?? ''))
|
|
80
|
+
}
|
|
81
|
+
return
|
|
82
|
+
}
|
|
83
|
+
// Strip every attribute that isn't in the per-tag allow-list, and
|
|
84
|
+
// re-validate `a[href]` against `isSafeHref`.
|
|
85
|
+
const allowedAttrs = ALLOWED_ATTRS_BY_TAG[tag] ?? new Set<string>()
|
|
86
|
+
for (const attr of Array.from(el.attributes)) {
|
|
87
|
+
if (!allowedAttrs.has(attr.name.toLowerCase())) {
|
|
88
|
+
el.removeAttribute(attr.name)
|
|
89
|
+
continue
|
|
90
|
+
}
|
|
91
|
+
if (tag === 'A' && attr.name.toLowerCase() === 'href' && !isSafeHref(attr.value)) {
|
|
92
|
+
el.removeAttribute(attr.name)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
// External-link hardening: every surviving anchor opens safely.
|
|
96
|
+
if (tag === 'A' && el.hasAttribute('href')) {
|
|
97
|
+
el.setAttribute('rel', 'noopener noreferrer')
|
|
98
|
+
el.setAttribute('target', '_blank')
|
|
99
|
+
}
|
|
100
|
+
// Recurse over children — copy the live list first so removals don't
|
|
101
|
+
// skip siblings.
|
|
102
|
+
for (const child of Array.from(el.childNodes)) {
|
|
103
|
+
sanitizeNode(child)
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function sanitizeHtml(raw: string | undefined | null): string {
|
|
108
|
+
if (!raw) return ''
|
|
109
|
+
const doc = new DOMParser().parseFromString(`<div>${raw}</div>`, 'text/html')
|
|
110
|
+
const root = doc.body.firstElementChild
|
|
111
|
+
if (!root) return ''
|
|
112
|
+
for (const child of Array.from(root.childNodes)) {
|
|
113
|
+
sanitizeNode(child)
|
|
114
|
+
}
|
|
115
|
+
return root.innerHTML
|
|
116
|
+
}
|