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.
@@ -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: 2,
23
- maximumFractionDigits: 2,
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
+ }
@@ -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
+ }