mgv-backoffice 1.44.0 → 1.46.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.
@@ -0,0 +1,81 @@
1
+ import { getCurrentScope, onScopeDispose } from 'vue'
2
+
3
+ /** One begun request: its abort signal and whether it is still the latest. */
4
+ export interface LatestRequestToken {
5
+ /** Pass to fetch(); aborted as soon as a newer request begins (or on cancel). */
6
+ signal: AbortSignal
7
+ /**
8
+ * True while no newer begin() / cancel() has happened. Check it after every
9
+ * await before writing state, so a superseded response can never clobber
10
+ * the newer one's — and only the latest call clears its loading flag.
11
+ */
12
+ isLatest: () => boolean
13
+ }
14
+
15
+ /**
16
+ * "Latest request wins" guard for views that refetch on every filter change,
17
+ * poll, or Refresh click. Each `begin()` aborts the previous in-flight request
18
+ * on the wire and hands back a fresh AbortSignal plus an `isLatest()` check
19
+ * (a monotonic request id), so a slow, stale response that still lands can be
20
+ * dropped instead of racing the fresh one.
21
+ *
22
+ * `cancel()` aborts the in-flight request and invalidates it without starting
23
+ * a new one (e.g. the selection was cleared). When called inside a component
24
+ * setup / effect scope, the in-flight request is cancelled automatically on
25
+ * unmount.
26
+ *
27
+ * ```ts
28
+ * const latest = useLatestRequest()
29
+ * async function load() {
30
+ * const req = latest.begin()
31
+ * loading.value = true
32
+ * try {
33
+ * const res = await fetchThings(req.signal)
34
+ * if (!req.isLatest()) return
35
+ * rows.value = res
36
+ * } catch (e) {
37
+ * if (!req.isLatest() || isAbortError(e)) return
38
+ * error.value = String(e)
39
+ * } finally {
40
+ * if (req.isLatest()) loading.value = false
41
+ * }
42
+ * }
43
+ * ```
44
+ */
45
+ export function useLatestRequest(): {
46
+ begin: () => LatestRequestToken
47
+ cancel: () => void
48
+ } {
49
+ let seq = 0
50
+ let ctrl: AbortController | null = null
51
+
52
+ function begin(): LatestRequestToken {
53
+ const mine = ++seq
54
+ ctrl?.abort()
55
+ const c = new AbortController()
56
+ ctrl = c
57
+ return { signal: c.signal, isLatest: () => mine === seq }
58
+ }
59
+
60
+ function cancel(): void {
61
+ ++seq
62
+ ctrl?.abort()
63
+ ctrl = null
64
+ }
65
+
66
+ if (getCurrentScope()) onScopeDispose(cancel)
67
+
68
+ return { begin, cancel }
69
+ }
70
+
71
+ /**
72
+ * True for the rejection an aborted fetch() produces (a DOMException — or, in
73
+ * some runtimes, a plain Error — named "AbortError"). Superseded / cancelled
74
+ * requests should be swallowed silently rather than shown as an error.
75
+ */
76
+ export function isAbortError(e: unknown): boolean {
77
+ return (
78
+ (typeof DOMException !== 'undefined' && e instanceof DOMException && e.name === 'AbortError') ||
79
+ (e instanceof Error && e.name === 'AbortError')
80
+ )
81
+ }
package/src/index.ts CHANGED
@@ -51,6 +51,7 @@ export { default as BaseFilterChip } from './components/BaseFilterChip.vue'
51
51
  export { default as BaseCredentialsForm } from './components/BaseCredentialsForm.vue'
52
52
  export { default as BasePillPickerModal } from './components/BasePillPickerModal.vue'
53
53
  export { default as BaseBarDistribution } from './components/BaseBarDistribution.vue'
54
+ export { default as BaseGroupedChecklist } from './components/BaseGroupedChecklist.vue'
54
55
  export type { CredentialsView, CredentialsUpdate } from './components/BaseCredentialsForm.vue'
55
56
 
56
57
  // Components — third-party wrappers
@@ -85,6 +86,9 @@ export { useFieldClasses } from './composables/useFieldClasses'
85
86
  export type { FieldClasses } from './composables/useFieldClasses'
86
87
  export { usePolling } from './composables/usePolling'
87
88
  export type { UsePollingOptions } from './composables/usePolling'
89
+ export { useLatestRequest, isAbortError } from './composables/useLatestRequest'
90
+ export { useDirtyState, stableStringify } from './composables/useDirtyState'
91
+ export type { LatestRequestToken } from './composables/useLatestRequest'
88
92
 
89
93
  // Enums
90
94
  export { AlertEnum } from './enums/AlertEnum'
@@ -111,6 +115,7 @@ export type { SpecField, SpecFieldType, SpecFieldValue } from './types/specField
111
115
  export type { StatBreakdownItem } from './types/statBreakdown'
112
116
  export type { PillPickerItem } from './types/pillPicker'
113
117
  export type { DistributionBar } from './types/distribution'
118
+ export type { ChecklistGroup } from './types/checklist'
114
119
 
115
120
  // Utils
116
121
  export { getBaseColor, getBaseColorOf } from './utils/util'
@@ -138,6 +143,7 @@ export {
138
143
  fmtPrice,
139
144
  fmtPct,
140
145
  fmtUsd,
146
+ fmtMoney,
141
147
  fmtDuration,
142
148
  formatJson,
143
149
  stringifyValue,
@@ -0,0 +1,6 @@
1
+ /** One group of a BaseGroupedChecklist: a heading plus its selectable item ids. */
2
+ export interface ChecklistGroup {
3
+ key: string
4
+ label: string
5
+ items: string[]
6
+ }
@@ -8,6 +8,7 @@ import {
8
8
  fmtPrice,
9
9
  fmtPct,
10
10
  fmtUsd,
11
+ fmtMoney,
11
12
  fmtDuration,
12
13
  formatJson,
13
14
  stringifyValue,
@@ -262,3 +263,28 @@ describe('stringifyValue', () => {
262
263
  expect(stringifyValue(1n)).toBe('1')
263
264
  })
264
265
  })
266
+
267
+ describe('fmtMoney', () => {
268
+ it('puts the minus before the currency and groups digits', () => {
269
+ expect(fmtMoney(-2.26)).toBe('-$2.26')
270
+ expect(fmtMoney(1234.5)).toBe(`$${(1234.5).toLocaleString(undefined, { minimumFractionDigits: 2 })}`)
271
+ expect(fmtMoney(0)).toBe('$0.00')
272
+ })
273
+
274
+ it('honours digits and currency', () => {
275
+ expect(fmtMoney(1.5, 3)).toBe('$1.500')
276
+ expect(fmtMoney(-3, 2, '€')).toBe('-€3.00')
277
+ })
278
+
279
+ it('does not sign a value that rounds to zero', () => {
280
+ expect(fmtMoney(-0.001)).toBe('$0.00')
281
+ })
282
+
283
+ it('renders an em-dash for missing input and accepts numeric strings', () => {
284
+ expect(fmtMoney(null)).toBe('—')
285
+ expect(fmtMoney(undefined)).toBe('—')
286
+ expect(fmtMoney('')).toBe('—')
287
+ expect(fmtMoney(NaN)).toBe('—')
288
+ expect(fmtMoney('-4.5')).toBe('-$4.50')
289
+ })
290
+ })
@@ -111,6 +111,31 @@ export function fmtUsd(v: number): string {
111
111
  return `${sign}$${Math.abs(v).toFixed(2)}`
112
112
  }
113
113
 
114
+ /**
115
+ * Format a money amount with the sign BEFORE the currency symbol ("-$2.26",
116
+ * never "$-2.26") and locale digit grouping ("$1,234.50"). Positive values
117
+ * carry no sign — prefix an explicit '+' where a gain should read signed.
118
+ * Renders an em-dash for null/undefined/non-finite input; accepts numeric
119
+ * strings so BigDecimal-as-string payloads pass through.
120
+ */
121
+ export function fmtMoney(
122
+ n: number | string | null | undefined,
123
+ digits = 2,
124
+ currency = '$',
125
+ ): string {
126
+ if (n === null || n === undefined) return EM_DASH
127
+ if (typeof n === 'string' && n.trim() === '') return EM_DASH
128
+ const v = typeof n === 'string' ? Number(n) : n
129
+ if (!isFinite(v)) return EM_DASH
130
+ const abs = Math.abs(v).toLocaleString(undefined, {
131
+ minimumFractionDigits: digits,
132
+ maximumFractionDigits: digits,
133
+ })
134
+ // A value that rounds to zero ("-0.00") reads unsigned.
135
+ const negative = v < 0 && Number(Math.abs(v).toFixed(digits)) !== 0
136
+ return `${negative ? '-' : ''}${currency}${abs}`
137
+ }
138
+
114
139
  /**
115
140
  * Format an ISO date string / epoch as a "Mon D, YYYY" calendar label
116
141
  * (en-US, e.g. "Jan 5, 2026"). Falls back to the raw value on parse