mgv-backoffice 1.28.0 → 1.30.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/src/index.ts CHANGED
@@ -45,6 +45,9 @@ export { default as BaseSegmentedControl } from './components/BaseSegmentedContr
45
45
  export { default as BaseTable } from './components/BaseTable.vue'
46
46
  export { default as BaseSpecFields } from './components/BaseSpecFields.vue'
47
47
  export { default as BaseStatBreakdown } from './components/BaseStatBreakdown.vue'
48
+ export { default as BaseFilterChip } from './components/BaseFilterChip.vue'
49
+ export { default as BaseCredentialsForm } from './components/BaseCredentialsForm.vue'
50
+ export type { CredentialsView, CredentialsUpdate } from './components/BaseCredentialsForm.vue'
48
51
 
49
52
  // Composables
50
53
  export { useTheme, initTheme } from './composables/useTheme'
@@ -58,6 +61,7 @@ export { useMobileSidebar } from './composables/useMobileSidebar'
58
61
  export { useSidebarCollapse } from './composables/useSidebarCollapse'
59
62
  export type { UseSidebarCollapseOptions } from './composables/useSidebarCollapse'
60
63
  export { useNotifications } from './composables/useNotifications'
64
+ export { useQueryParamSync } from './composables/useQueryParamSync'
61
65
 
62
66
  // Enums
63
67
  export { AlertEnum } from './enums/AlertEnum'
@@ -96,11 +100,13 @@ export {
96
100
  fmtNumber,
97
101
  fmtDate,
98
102
  fmtDateTime,
103
+ fmtDateTimeMs,
99
104
  fmtDateShort,
100
105
  fmtPrice,
101
106
  fmtPct,
102
107
  fmtUsd,
103
108
  fmtDuration,
104
109
  } from './utils/format'
110
+ export { buildSpecParams, firstInvalidNumericSpec } from './utils/specForm'
105
111
  export { computePnL } from './utils/pnl'
106
112
  export type { PnL, PnLInputs } from './utils/pnl'
@@ -11,6 +11,11 @@ export interface SpecField {
11
11
  key: string
12
12
  label: string
13
13
  type: SpecFieldType
14
+ /**
15
+ * Default value the form seeds the field with. `null` marks the field as
16
+ * OPTIONAL for `firstInvalidNumericSpec` (blank = "knob disabled").
17
+ */
18
+ default?: SpecFieldValue | null
14
19
  /** Choices for `type: 'select'`. */
15
20
  options?: string[] | null
16
21
  /** Step for numeric inputs; defaults to 1 (integer) / 0.01 (decimal). */
@@ -3,6 +3,7 @@ import {
3
3
  fmtNumber,
4
4
  fmtDate,
5
5
  fmtDateTime,
6
+ fmtDateTimeMs,
6
7
  fmtDateShort,
7
8
  fmtPrice,
8
9
  fmtPct,
@@ -10,6 +11,21 @@ import {
10
11
  fmtDuration,
11
12
  } from './format'
12
13
 
14
+ describe('fmtDateTimeMs', () => {
15
+ it('keeps the millisecond fraction so same-second rows stay distinguishable', () => {
16
+ const out = fmtDateTimeMs('2026-06-08T14:30:51.123Z')
17
+ expect(out).toMatch(/51[.,:]?\d*123|123/) // ms fraction present, locale separators vary
18
+ })
19
+
20
+ it('accepts epoch millis', () => {
21
+ expect(fmtDateTimeMs(0)).toMatch(/19?70/)
22
+ })
23
+
24
+ it('falls back to the raw value for unparseable input', () => {
25
+ expect(fmtDateTimeMs('not-a-date')).toBe('Invalid Date')
26
+ })
27
+ })
28
+
13
29
  const EM_DASH = '—'
14
30
 
15
31
  // Fraction digits are locale-stable even when the grouping/decimal
@@ -55,6 +55,29 @@ export function fmtDateShort(ms: number): string {
55
55
  return new Date(ms).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
56
56
  }
57
57
 
58
+ /**
59
+ * Format an ISO string / epoch-millis as a full 24-hour locale date-time WITH
60
+ * the millisecond fraction — for dense feeds where several rows can land in
61
+ * the same second and must stay distinguishable. Falls back to the raw value
62
+ * when the input can't be parsed.
63
+ */
64
+ export function fmtDateTimeMs(s: string | number): string {
65
+ try {
66
+ return new Date(s).toLocaleString(undefined, {
67
+ year: 'numeric',
68
+ month: '2-digit',
69
+ day: '2-digit',
70
+ hour: '2-digit',
71
+ minute: '2-digit',
72
+ second: '2-digit',
73
+ fractionalSecondDigits: 3,
74
+ hour12: false,
75
+ })
76
+ } catch {
77
+ return String(s)
78
+ }
79
+ }
80
+
58
81
  /**
59
82
  * Format a price with precision that scales to magnitude: penny stocks and
60
83
  * sub-cent crypto get more decimals so the value never collapses to "0.00".
@@ -0,0 +1,53 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import type { SpecField } from '../types/specField'
3
+ import { buildSpecParams, firstInvalidNumericSpec } from './specForm'
4
+
5
+ // Mirrors a typical backend catalogue shape: required numerics carry a
6
+ // default, the optional field carries `default: null` (the optionality
7
+ // convention — blank means "knob disabled").
8
+ const specs: SpecField[] = [
9
+ { key: 'amountPerSymbol', label: 'Amount per symbol ($)', type: 'decimal', default: 4, min: 1, step: 0.01, options: null, help: '' },
10
+ { key: 'stopLossPercent', label: 'Stop loss (%)', type: 'decimal', default: null, min: 0.1, step: 0.1, options: null, help: '' },
11
+ ]
12
+
13
+ describe('firstInvalidNumericSpec', () => {
14
+ it('flags a blank required numeric field', () => {
15
+ expect(firstInvalidNumericSpec(specs, { amountPerSymbol: '', stopLossPercent: 1 }))
16
+ .toBe('Amount per symbol ($)')
17
+ })
18
+
19
+ it.each([['' as const], [null as never], [undefined as never]])(
20
+ 'accepts a blank optional field (cleared → %j)',
21
+ (blank) => {
22
+ expect(firstInvalidNumericSpec(specs, { amountPerSymbol: 10, stopLossPercent: blank }))
23
+ .toBeNull()
24
+ },
25
+ )
26
+
27
+ it('still validates a non-blank value on an optional field', () => {
28
+ expect(firstInvalidNumericSpec(specs, { amountPerSymbol: 10, stopLossPercent: NaN }))
29
+ .toBe('Stop loss (%)')
30
+ expect(firstInvalidNumericSpec(specs, { amountPerSymbol: 10, stopLossPercent: 2.5 }))
31
+ .toBeNull()
32
+ })
33
+
34
+ it('accepts a fully valid value set and tolerates missing specs', () => {
35
+ expect(firstInvalidNumericSpec(specs, { amountPerSymbol: 10, stopLossPercent: 1 })).toBeNull()
36
+ expect(firstInvalidNumericSpec(undefined, {})).toBeNull()
37
+ })
38
+ })
39
+
40
+ describe('buildSpecParams', () => {
41
+ it('seeds from defaults, keeping caller-supplied overlaps', () => {
42
+ expect(buildSpecParams(specs, { amountPerSymbol: 10 }).amountPerSymbol).toBe(10)
43
+ expect(buildSpecParams(specs, {}).amountPerSymbol).toBe(4)
44
+ })
45
+
46
+ it('seeds an optional field with null (renders as an empty input)', () => {
47
+ expect(buildSpecParams(specs, {}).stopLossPercent).toBeNull()
48
+ })
49
+
50
+ it('returns an empty map when there are no specs', () => {
51
+ expect(buildSpecParams(undefined, { anything: 1 })).toEqual({})
52
+ })
53
+ })
@@ -0,0 +1,48 @@
1
+ import type { SpecField, SpecFieldValue } from '../types/specField'
2
+
3
+ /**
4
+ * Helpers for spec-driven forms (the value maps behind BaseSpecFields), so
5
+ * param seeding and numeric validation stay single-sourced across consumers.
6
+ */
7
+
8
+ /**
9
+ * Build the value map for a spec set: keep any overlapping values the caller
10
+ * already has (e.g. a shared amount field carries across spec sets), otherwise
11
+ * fall back to the spec's default. No specs → empty map. A `default: null`
12
+ * seeds the field with null so it renders as an empty (optional) input.
13
+ */
14
+ export function buildSpecParams(
15
+ specs: readonly SpecField[] | undefined,
16
+ existing: Record<string, SpecFieldValue>,
17
+ ): Record<string, SpecFieldValue> {
18
+ const out: Record<string, SpecFieldValue> = {}
19
+ if (!specs) return out
20
+ for (const spec of specs) {
21
+ const prev = existing[spec.key]
22
+ out[spec.key] = prev !== undefined && prev !== null ? prev : (spec.default as SpecFieldValue)
23
+ }
24
+ return out
25
+ }
26
+
27
+ /**
28
+ * Label of the first blank / NaN numeric field (a cleared number input becomes
29
+ * '' under number coercion), or null when every numeric field holds a number.
30
+ * A spec whose `default` is null is OPTIONAL: blank means "knob disabled" and
31
+ * must pass; a non-blank value on an optional field is still validated as a
32
+ * number.
33
+ */
34
+ export function firstInvalidNumericSpec(
35
+ specs: readonly SpecField[] | undefined,
36
+ params: Record<string, SpecFieldValue>,
37
+ ): string | null {
38
+ if (!specs) return null
39
+ for (const spec of specs) {
40
+ if (spec.type === 'decimal' || spec.type === 'integer') {
41
+ const v = params[spec.key]
42
+ const blank = v === '' || v === null || v === undefined
43
+ if (spec.default === null && blank) continue
44
+ if (typeof v !== 'number' || Number.isNaN(v)) return spec.label
45
+ }
46
+ }
47
+ return null
48
+ }