@open-mercato/core 0.6.7-develop.6751.1.ac823a3d26 → 0.6.7-develop.6758.1.697eade236
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/.turbo/turbo-build.log +1 -1
- package/dist/modules/attachments/api/route.js +16 -2
- package/dist/modules/attachments/api/route.js.map +2 -2
- package/dist/modules/attachments/lib/upload-limits.js +33 -0
- package/dist/modules/attachments/lib/upload-limits.js.map +2 -2
- package/dist/modules/communication_channels/api/post/test-seed/route.js +52 -2
- package/dist/modules/communication_channels/api/post/test-seed/route.js.map +2 -2
- package/dist/modules/currencies/di.js +4 -0
- package/dist/modules/currencies/di.js.map +2 -2
- package/dist/modules/currencies/services/baseCurrencyService.js +42 -0
- package/dist/modules/currencies/services/baseCurrencyService.js.map +7 -0
- package/dist/modules/customers/analytics.js +3 -1
- package/dist/modules/customers/analytics.js.map +2 -2
- package/dist/modules/customers/api/deals/aggregate/route.js +5 -4
- package/dist/modules/customers/api/deals/aggregate/route.js.map +2 -2
- package/dist/modules/customers/api/deals/summary/route.js +5 -4
- package/dist/modules/customers/api/deals/summary/route.js.map +2 -2
- package/dist/modules/customers/lib/optionalBaseCurrency.js +19 -0
- package/dist/modules/customers/lib/optionalBaseCurrency.js.map +7 -0
- package/dist/modules/dashboards/api/widgets/data/batch/route.js +8 -1
- package/dist/modules/dashboards/api/widgets/data/batch/route.js.map +2 -2
- package/dist/modules/dashboards/api/widgets/data/route.js +8 -1
- package/dist/modules/dashboards/api/widgets/data/route.js.map +2 -2
- package/dist/modules/dashboards/api/widgets/data/schema.js +2 -1
- package/dist/modules/dashboards/api/widgets/data/schema.js.map +2 -2
- package/dist/modules/dashboards/components/UnlabelledAmountNotice.js +33 -0
- package/dist/modules/dashboards/components/UnlabelledAmountNotice.js.map +7 -0
- package/dist/modules/dashboards/lib/aggregations.js +17 -0
- package/dist/modules/dashboards/lib/aggregations.js.map +2 -2
- package/dist/modules/dashboards/lib/formatters.js +84 -11
- package/dist/modules/dashboards/lib/formatters.js.map +2 -2
- package/dist/modules/dashboards/lib/optionalBaseCurrency.js +11 -0
- package/dist/modules/dashboards/lib/optionalBaseCurrency.js.map +7 -0
- package/dist/modules/dashboards/services/widgetDataService.js +86 -6
- package/dist/modules/dashboards/services/widgetDataService.js.map +2 -2
- package/dist/modules/dashboards/subscribers/invalidateWidgetDataCache.js +26 -0
- package/dist/modules/dashboards/subscribers/invalidateWidgetDataCache.js.map +7 -0
- package/dist/modules/dashboards/widgets/dashboard/aov-kpi/widget.client.js +9 -3
- package/dist/modules/dashboards/widgets/dashboard/aov-kpi/widget.client.js.map +2 -2
- package/dist/modules/dashboards/widgets/dashboard/pipeline-summary/widget.client.js +10 -4
- package/dist/modules/dashboards/widgets/dashboard/pipeline-summary/widget.client.js.map +2 -2
- package/dist/modules/dashboards/widgets/dashboard/revenue-kpi/widget.client.js +9 -3
- package/dist/modules/dashboards/widgets/dashboard/revenue-kpi/widget.client.js.map +2 -2
- package/dist/modules/dashboards/widgets/dashboard/revenue-trend/widget.client.js +8 -3
- package/dist/modules/dashboards/widgets/dashboard/revenue-trend/widget.client.js.map +2 -2
- package/dist/modules/dashboards/widgets/dashboard/sales-by-region/widget.client.js +26 -18
- package/dist/modules/dashboards/widgets/dashboard/sales-by-region/widget.client.js.map +2 -2
- package/dist/modules/dashboards/widgets/dashboard/top-customers/widget.client.js +22 -14
- package/dist/modules/dashboards/widgets/dashboard/top-customers/widget.client.js.map +2 -2
- package/dist/modules/dashboards/widgets/dashboard/top-products/widget.client.js +10 -4
- package/dist/modules/dashboards/widgets/dashboard/top-products/widget.client.js.map +2 -2
- package/dist/modules/notifications/api/route.js +62 -3
- package/dist/modules/notifications/api/route.js.map +2 -2
- package/dist/modules/sales/analytics.js +7 -3
- package/dist/modules/sales/analytics.js.map +2 -2
- package/dist/modules/sales/api/document-history/route.js +2 -1
- package/dist/modules/sales/api/document-history/route.js.map +2 -2
- package/dist/modules/sales/lib/historyHelpers.js +69 -1
- package/dist/modules/sales/lib/historyHelpers.js.map +2 -2
- package/dist/modules/sales/widgets/injection/document-history/widget.client.js +51 -2
- package/dist/modules/sales/widgets/injection/document-history/widget.client.js.map +2 -2
- package/package.json +7 -7
- package/src/modules/attachments/api/route.ts +16 -2
- package/src/modules/attachments/lib/upload-limits.ts +35 -0
- package/src/modules/communication_channels/api/post/test-seed/route.ts +69 -2
- package/src/modules/currencies/di.ts +4 -1
- package/src/modules/currencies/services/baseCurrencyService.ts +59 -0
- package/src/modules/customers/analytics.ts +2 -0
- package/src/modules/customers/api/deals/aggregate/route.ts +5 -4
- package/src/modules/customers/api/deals/summary/route.ts +5 -4
- package/src/modules/customers/lib/optionalBaseCurrency.ts +34 -0
- package/src/modules/dashboards/api/widgets/data/batch/route.ts +8 -1
- package/src/modules/dashboards/api/widgets/data/route.ts +8 -1
- package/src/modules/dashboards/api/widgets/data/schema.ts +1 -0
- package/src/modules/dashboards/components/UnlabelledAmountNotice.tsx +43 -0
- package/src/modules/dashboards/i18n/de.json +2 -0
- package/src/modules/dashboards/i18n/en.json +2 -0
- package/src/modules/dashboards/i18n/es.json +2 -0
- package/src/modules/dashboards/i18n/pl.json +2 -0
- package/src/modules/dashboards/lib/aggregations.ts +35 -0
- package/src/modules/dashboards/lib/formatters.ts +137 -12
- package/src/modules/dashboards/lib/optionalBaseCurrency.ts +22 -0
- package/src/modules/dashboards/services/widgetDataService.ts +110 -4
- package/src/modules/dashboards/subscribers/invalidateWidgetDataCache.ts +32 -0
- package/src/modules/dashboards/widgets/dashboard/aov-kpi/widget.client.tsx +9 -3
- package/src/modules/dashboards/widgets/dashboard/pipeline-summary/widget.client.tsx +9 -3
- package/src/modules/dashboards/widgets/dashboard/revenue-kpi/widget.client.tsx +9 -3
- package/src/modules/dashboards/widgets/dashboard/revenue-trend/widget.client.tsx +7 -2
- package/src/modules/dashboards/widgets/dashboard/sales-by-region/widget.client.tsx +25 -15
- package/src/modules/dashboards/widgets/dashboard/top-customers/widget.client.tsx +21 -11
- package/src/modules/dashboards/widgets/dashboard/top-products/widget.client.tsx +9 -3
- package/src/modules/notifications/api/route.ts +88 -3
- package/src/modules/sales/analytics.ts +4 -0
- package/src/modules/sales/api/document-history/route.ts +1 -0
- package/src/modules/sales/i18n/de.json +19 -0
- package/src/modules/sales/i18n/en.json +19 -0
- package/src/modules/sales/i18n/es.json +19 -0
- package/src/modules/sales/i18n/pl.json +19 -0
- package/src/modules/sales/lib/historyHelpers.ts +88 -1
- package/src/modules/sales/widgets/injection/document-history/widget.client.tsx +64 -3
|
@@ -1,36 +1,161 @@
|
|
|
1
1
|
export type FormatCurrencyOptions = {
|
|
2
|
-
currency?: string
|
|
2
|
+
currency?: string | null
|
|
3
|
+
locale?: string
|
|
3
4
|
minimumFractionDigits?: number
|
|
4
5
|
maximumFractionDigits?: number
|
|
5
6
|
}
|
|
6
7
|
|
|
7
|
-
export
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
8
|
+
export type FormatCurrencyCompactOptions = {
|
|
9
|
+
currency?: string | null
|
|
10
|
+
locale?: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const CURRENCY_CODE_PATTERN = /^[A-Za-z]{3}$/
|
|
14
|
+
const NUMBER_FORMATTER_CACHE_LIMIT = 128
|
|
15
|
+
const numberFormatterCache = new Map<string, Intl.NumberFormat>()
|
|
16
|
+
|
|
17
|
+
function normalizeCurrencyCode(currency?: string | null): string | null {
|
|
18
|
+
if (typeof currency !== 'string') return null
|
|
19
|
+
const trimmed = currency.trim()
|
|
20
|
+
if (!CURRENCY_CODE_PATTERN.test(trimmed)) return null
|
|
21
|
+
return trimmed.toUpperCase()
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function getNumberFormatter(
|
|
25
|
+
locale: string | undefined,
|
|
26
|
+
options: Intl.NumberFormatOptions,
|
|
27
|
+
): Intl.NumberFormat {
|
|
28
|
+
const key = `${locale ?? ''}:${JSON.stringify(options)}`
|
|
29
|
+
const cached = numberFormatterCache.get(key)
|
|
30
|
+
if (cached) return cached
|
|
31
|
+
const formatter = new Intl.NumberFormat(locale, options)
|
|
32
|
+
if (numberFormatterCache.size >= NUMBER_FORMATTER_CACHE_LIMIT) {
|
|
33
|
+
const oldest = numberFormatterCache.keys().next().value
|
|
34
|
+
if (oldest) numberFormatterCache.delete(oldest)
|
|
35
|
+
}
|
|
36
|
+
numberFormatterCache.set(key, formatter)
|
|
37
|
+
return formatter
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function formatDecimal(
|
|
41
|
+
value: number,
|
|
42
|
+
minimumFractionDigits: number,
|
|
43
|
+
maximumFractionDigits: number,
|
|
44
|
+
locale?: string,
|
|
45
|
+
): string {
|
|
46
|
+
return getNumberFormatter(locale, {
|
|
47
|
+
style: 'decimal',
|
|
12
48
|
minimumFractionDigits,
|
|
13
49
|
maximumFractionDigits,
|
|
14
50
|
}).format(value)
|
|
15
51
|
}
|
|
16
52
|
|
|
53
|
+
/**
|
|
54
|
+
* Formats a monetary amount. Without a currency code the amount renders as a plain
|
|
55
|
+
* number: analytics values carry no currency of their own, and labelling them with a
|
|
56
|
+
* guessed one (see #4620) is worse than leaving them unlabelled.
|
|
57
|
+
*/
|
|
58
|
+
export function formatCurrency(value: number, options: FormatCurrencyOptions = {}): string {
|
|
59
|
+
const { currency, locale, minimumFractionDigits = 0, maximumFractionDigits = 0 } = options
|
|
60
|
+
const code = normalizeCurrencyCode(currency)
|
|
61
|
+
if (!code) return formatDecimal(value, minimumFractionDigits, maximumFractionDigits, locale)
|
|
62
|
+
try {
|
|
63
|
+
return getNumberFormatter(locale, {
|
|
64
|
+
style: 'currency',
|
|
65
|
+
currency: code,
|
|
66
|
+
minimumFractionDigits,
|
|
67
|
+
maximumFractionDigits,
|
|
68
|
+
}).format(value)
|
|
69
|
+
} catch {
|
|
70
|
+
return `${formatDecimal(value, minimumFractionDigits, maximumFractionDigits, locale)} ${code}`
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
17
74
|
export function formatCurrencyWithDecimals(value: number, options: FormatCurrencyOptions = {}): string {
|
|
18
75
|
return formatCurrency(value, { minimumFractionDigits: 2, maximumFractionDigits: 2, ...options })
|
|
19
76
|
}
|
|
20
77
|
|
|
21
|
-
|
|
78
|
+
function formatWithLiteralSymbol(value: number, symbol: string): string {
|
|
22
79
|
if (Math.abs(value) >= 1_000_000) {
|
|
23
|
-
return `${
|
|
80
|
+
return `${symbol}${(value / 1_000_000).toFixed(1)}M`
|
|
24
81
|
}
|
|
25
82
|
if (Math.abs(value) >= 1_000) {
|
|
26
|
-
return `${
|
|
83
|
+
return `${symbol}${(value / 1_000).toFixed(1)}K`
|
|
27
84
|
}
|
|
28
|
-
return `${
|
|
85
|
+
return `${symbol}${value.toFixed(0)}`
|
|
29
86
|
}
|
|
30
87
|
|
|
31
|
-
export function
|
|
88
|
+
export function formatCurrencyCompact(
|
|
89
|
+
value: number,
|
|
90
|
+
currencyOrOptions: string | null | FormatCurrencyCompactOptions = {},
|
|
91
|
+
): string {
|
|
92
|
+
if (typeof currencyOrOptions === 'string' && !normalizeCurrencyCode(currencyOrOptions)) {
|
|
93
|
+
return formatWithLiteralSymbol(value, currencyOrOptions)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const options = typeof currencyOrOptions === 'string'
|
|
97
|
+
? { currency: currencyOrOptions }
|
|
98
|
+
: (currencyOrOptions ?? {})
|
|
99
|
+
const code = normalizeCurrencyCode(options.currency)
|
|
100
|
+
const compact = Math.abs(value) >= 1_000
|
|
101
|
+
const formatOptions: Intl.NumberFormatOptions = {
|
|
102
|
+
style: code ? 'currency' : 'decimal',
|
|
103
|
+
notation: compact ? 'compact' : 'standard',
|
|
104
|
+
compactDisplay: 'short',
|
|
105
|
+
minimumFractionDigits: compact ? 1 : 0,
|
|
106
|
+
maximumFractionDigits: compact ? 1 : 0,
|
|
107
|
+
}
|
|
108
|
+
if (code) formatOptions.currency = code
|
|
109
|
+
|
|
110
|
+
try {
|
|
111
|
+
return getNumberFormatter(options.locale, formatOptions).format(value)
|
|
112
|
+
} catch {
|
|
113
|
+
const formatted = getNumberFormatter(options.locale, {
|
|
114
|
+
style: 'decimal',
|
|
115
|
+
notation: compact ? 'compact' : 'standard',
|
|
116
|
+
compactDisplay: 'short',
|
|
117
|
+
minimumFractionDigits: compact ? 1 : 0,
|
|
118
|
+
maximumFractionDigits: compact ? 1 : 0,
|
|
119
|
+
}).format(value)
|
|
120
|
+
return code ? `${formatted} ${code}` : formatted
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function formatCurrencySafe(
|
|
125
|
+
value: unknown,
|
|
126
|
+
fallback = '--',
|
|
127
|
+
options: FormatCurrencyOptions = {},
|
|
128
|
+
): string {
|
|
32
129
|
if (value === null || value === undefined) return fallback
|
|
33
130
|
const num = Number(value)
|
|
34
131
|
if (!Number.isFinite(num)) return fallback
|
|
35
|
-
return formatCurrency(num)
|
|
132
|
+
return formatCurrency(num, options)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export type CurrencyFormatters = {
|
|
136
|
+
currency: string | null
|
|
137
|
+
format: (value: number) => string
|
|
138
|
+
formatWithDecimals: (value: number) => string
|
|
139
|
+
formatCompact: (value: number) => string
|
|
140
|
+
formatSafe: (value: unknown) => string
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Binds the resolved currency once so widgets can hand chart and KPI components a
|
|
145
|
+
* stable single-argument formatter — passing `formatCurrency` by reference is what
|
|
146
|
+
* made its currency option unreachable in the first place (#4620).
|
|
147
|
+
*/
|
|
148
|
+
export function createCurrencyFormatters(
|
|
149
|
+
currency?: string | null,
|
|
150
|
+
fallback = '--',
|
|
151
|
+
locale?: string,
|
|
152
|
+
): CurrencyFormatters {
|
|
153
|
+
const code = normalizeCurrencyCode(currency)
|
|
154
|
+
return {
|
|
155
|
+
currency: code,
|
|
156
|
+
format: (value: number) => formatCurrency(value, { currency: code, locale }),
|
|
157
|
+
formatWithDecimals: (value: number) => formatCurrencyWithDecimals(value, { currency: code, locale }),
|
|
158
|
+
formatCompact: (value: number) => formatCurrencyCompact(value, { currency: code, locale }),
|
|
159
|
+
formatSafe: (value: unknown) => formatCurrencySafe(value, fallback, { currency: code, locale }),
|
|
160
|
+
}
|
|
36
161
|
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { AppContainer } from '@open-mercato/shared/lib/di/container'
|
|
2
|
+
|
|
3
|
+
export type BaseCurrencyResolution =
|
|
4
|
+
| { status: 'resolved'; code: string }
|
|
5
|
+
| { status: 'missing' | 'ambiguous' | 'unavailable' }
|
|
6
|
+
|
|
7
|
+
export type BaseCurrencyResolver = {
|
|
8
|
+
resolveBaseCurrency(scope: {
|
|
9
|
+
tenantId: string
|
|
10
|
+
organizationIds: string[]
|
|
11
|
+
}): Promise<BaseCurrencyResolution>
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function resolveOptionalBaseCurrencyResolver(
|
|
15
|
+
container: AppContainer,
|
|
16
|
+
): BaseCurrencyResolver | undefined {
|
|
17
|
+
try {
|
|
18
|
+
return container.resolve<BaseCurrencyResolver>('baseCurrencyService')
|
|
19
|
+
} catch {
|
|
20
|
+
return undefined
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
type AggregateFunction,
|
|
22
22
|
type DateGranularity,
|
|
23
23
|
buildAggregationQuery,
|
|
24
|
+
buildDistinctCurrencyQuery,
|
|
24
25
|
buildGroupSourceRowsQuery,
|
|
25
26
|
resolveGroupExpression,
|
|
26
27
|
} from '../lib/aggregations'
|
|
@@ -33,6 +34,9 @@ import {
|
|
|
33
34
|
parseExactDecimal,
|
|
34
35
|
} from '../lib/exactDecimal'
|
|
35
36
|
import type { AnalyticsRegistry } from './analyticsRegistry'
|
|
37
|
+
import type { BaseCurrencyResolver } from '../lib/optionalBaseCurrency'
|
|
38
|
+
|
|
39
|
+
const logger = createLogger('dashboards').child({ component: 'widget-data-service' })
|
|
36
40
|
|
|
37
41
|
const WIDGET_DATA_CACHE_TTL = 120_000
|
|
38
42
|
const WIDGET_DATA_SEGMENT_TTL = 86_400_000
|
|
@@ -47,8 +51,6 @@ const ENCRYPTED_GROUP_SCAN_LIMIT = 20_000
|
|
|
47
51
|
|
|
48
52
|
const SAFE_IDENTIFIER_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*$/
|
|
49
53
|
|
|
50
|
-
const logger = createLogger('dashboards').child({ component: 'widget-data' })
|
|
51
|
-
|
|
52
54
|
export class WidgetDataValidationError extends Error {
|
|
53
55
|
constructor(message: string) {
|
|
54
56
|
super(message)
|
|
@@ -213,6 +215,7 @@ export type WidgetDataResponse = {
|
|
|
213
215
|
metadata: {
|
|
214
216
|
fetchedAt: string
|
|
215
217
|
recordCount: number
|
|
218
|
+
currency?: string | null
|
|
216
219
|
}
|
|
217
220
|
}
|
|
218
221
|
|
|
@@ -226,6 +229,7 @@ export type WidgetDataServiceOptions = {
|
|
|
226
229
|
scope: WidgetDataScope
|
|
227
230
|
registry: AnalyticsRegistry
|
|
228
231
|
cache?: CacheStrategy
|
|
232
|
+
baseCurrencyResolver?: BaseCurrencyResolver
|
|
229
233
|
}
|
|
230
234
|
|
|
231
235
|
export class WidgetDataService {
|
|
@@ -233,12 +237,15 @@ export class WidgetDataService {
|
|
|
233
237
|
private scope: WidgetDataScope
|
|
234
238
|
private registry: AnalyticsRegistry
|
|
235
239
|
private cache?: CacheStrategy
|
|
240
|
+
private baseCurrencyResolver?: BaseCurrencyResolver
|
|
241
|
+
private baseCurrencyPromise?: Promise<string | null>
|
|
236
242
|
|
|
237
243
|
constructor(options: WidgetDataServiceOptions) {
|
|
238
244
|
this.em = options.em
|
|
239
245
|
this.scope = options.scope
|
|
240
246
|
this.registry = options.registry
|
|
241
247
|
this.cache = options.cache
|
|
248
|
+
this.baseCurrencyResolver = options.baseCurrencyResolver
|
|
242
249
|
}
|
|
243
250
|
|
|
244
251
|
private buildCacheKey(request: WidgetDataRequest): string {
|
|
@@ -278,11 +285,15 @@ export class WidgetDataService {
|
|
|
278
285
|
|
|
279
286
|
const shouldFetchComparison = Boolean(comparisonRange && request.dateRange)
|
|
280
287
|
|
|
281
|
-
const
|
|
288
|
+
const aggregatedRanges =
|
|
289
|
+
shouldFetchComparison && comparisonRange ? [dateRangeResolved, comparisonRange] : [dateRangeResolved]
|
|
290
|
+
|
|
291
|
+
const [mainResult, comparisonResult, currency] = await Promise.all([
|
|
282
292
|
this.executeQuery(request, dateRangeResolved),
|
|
283
293
|
shouldFetchComparison && comparisonRange
|
|
284
294
|
? this.executeQuery(request, comparisonRange)
|
|
285
295
|
: Promise.resolve<{ value: number | null; data: WidgetDataItem[] } | undefined>(undefined),
|
|
296
|
+
this.resolveCurrencyLabel(request, aggregatedRanges),
|
|
286
297
|
])
|
|
287
298
|
|
|
288
299
|
const response: WidgetDataResponse = {
|
|
@@ -291,6 +302,7 @@ export class WidgetDataService {
|
|
|
291
302
|
metadata: {
|
|
292
303
|
fetchedAt: now.toISOString(),
|
|
293
304
|
recordCount: mainResult.data.length || (mainResult.value !== null ? 1 : 0),
|
|
305
|
+
currency,
|
|
294
306
|
},
|
|
295
307
|
}
|
|
296
308
|
|
|
@@ -319,6 +331,99 @@ export class WidgetDataService {
|
|
|
319
331
|
return response
|
|
320
332
|
}
|
|
321
333
|
|
|
334
|
+
/**
|
|
335
|
+
* Resolves the currency the response may be labelled with. The base currency of the
|
|
336
|
+
* scope is only half the answer: it describes how the organizations are configured, not
|
|
337
|
+
* what the aggregated rows are actually denominated in. A PLN-based organization holding
|
|
338
|
+
* both PLN and EUR orders would otherwise sum them and present the total as PLN (#4676),
|
|
339
|
+
* so the base currency survives only when every aggregated row carries exactly that code.
|
|
340
|
+
*
|
|
341
|
+
* The check is deliberately conservative — anything it cannot prove resolves to `null`
|
|
342
|
+
* and the widgets render an explicitly unlabelled number, which is recoverable, while a
|
|
343
|
+
* confident wrong label is not.
|
|
344
|
+
*/
|
|
345
|
+
private async resolveCurrencyLabel(
|
|
346
|
+
request: WidgetDataRequest,
|
|
347
|
+
aggregatedRanges: Array<{ start: Date; end: Date } | undefined>,
|
|
348
|
+
): Promise<string | null> {
|
|
349
|
+
const baseCurrency = await this.resolveBaseCurrency()
|
|
350
|
+
if (!baseCurrency) return null
|
|
351
|
+
|
|
352
|
+
try {
|
|
353
|
+
for (const range of aggregatedRanges) {
|
|
354
|
+
const uniform = await this.rowsShareCurrency(request, range, baseCurrency)
|
|
355
|
+
if (!uniform) return null
|
|
356
|
+
}
|
|
357
|
+
return baseCurrency
|
|
358
|
+
} catch (err) {
|
|
359
|
+
logger.warn('Row-currency uniformity check failed; leaving the amount unlabelled', {
|
|
360
|
+
err,
|
|
361
|
+
entityType: request.entityType,
|
|
362
|
+
})
|
|
363
|
+
return null
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Reads the distinct per-row currencies of the rows one aggregation range would sum.
|
|
369
|
+
* Entities that declare no `currencyField` carry no per-row currency to contradict the
|
|
370
|
+
* base one, so they pass. An empty range has nothing to mislabel and also passes.
|
|
371
|
+
*
|
|
372
|
+
* A missing currency cannot prove the row uses the scope's base currency, so it fails
|
|
373
|
+
* closed just like a conflicting code. An empty range has no row to mislabel and passes.
|
|
374
|
+
*/
|
|
375
|
+
private async rowsShareCurrency(
|
|
376
|
+
request: WidgetDataRequest,
|
|
377
|
+
dateRange: { start: Date; end: Date } | undefined,
|
|
378
|
+
expectedCode: string,
|
|
379
|
+
): Promise<boolean> {
|
|
380
|
+
const query = buildDistinctCurrencyQuery({
|
|
381
|
+
entityType: request.entityType,
|
|
382
|
+
dateRange: dateRange && request.dateRange ? { field: request.dateRange.field, ...dateRange } : undefined,
|
|
383
|
+
filters: request.filters,
|
|
384
|
+
scope: this.scope,
|
|
385
|
+
registry: this.registry,
|
|
386
|
+
})
|
|
387
|
+
|
|
388
|
+
if (!query) return true
|
|
389
|
+
|
|
390
|
+
const rows = await this.em.getConnection().execute(query.sql, query.params)
|
|
391
|
+
const resultRows = (Array.isArray(rows) ? rows : []) as Array<Record<string, unknown>>
|
|
392
|
+
|
|
393
|
+
const recordedCodes = new Set<string>()
|
|
394
|
+
for (const row of resultRows) {
|
|
395
|
+
const code = typeof row.code === 'string' ? row.code.trim().toUpperCase() : ''
|
|
396
|
+
if (!code) return false
|
|
397
|
+
recordedCodes.add(code)
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
if (recordedCodes.size === 0) return true
|
|
401
|
+
if (recordedCodes.size > 1) return false
|
|
402
|
+
return recordedCodes.has(expectedCode)
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Resolves the base currency of the current scope so money widgets can label amounts
|
|
407
|
+
* with the tenant's own currency instead of a hard-coded default (#4620). The lookup
|
|
408
|
+
* is soft: a scope spanning organizations with different base currencies, a missing
|
|
409
|
+
* base currency, or an unavailable currencies module all resolve to `null`, and the
|
|
410
|
+
* widgets then render unlabelled numbers rather than a wrong currency.
|
|
411
|
+
*/
|
|
412
|
+
private resolveBaseCurrency(): Promise<string | null> {
|
|
413
|
+
if (!this.baseCurrencyPromise) {
|
|
414
|
+
const organizationIds = [...new Set(this.scope.organizationIds ?? [])]
|
|
415
|
+
if (!this.baseCurrencyResolver || organizationIds.length === 0) {
|
|
416
|
+
this.baseCurrencyPromise = Promise.resolve(null)
|
|
417
|
+
} else {
|
|
418
|
+
this.baseCurrencyPromise = this.baseCurrencyResolver
|
|
419
|
+
.resolveBaseCurrency({ tenantId: this.scope.tenantId, organizationIds })
|
|
420
|
+
.then((result) => result.status === 'resolved' ? result.code : null)
|
|
421
|
+
.catch(() => null)
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
return this.baseCurrencyPromise
|
|
425
|
+
}
|
|
426
|
+
|
|
322
427
|
private validateRequest(request: WidgetDataRequest): void {
|
|
323
428
|
if (!this.registry.isValidEntityType(request.entityType)) {
|
|
324
429
|
throw new WidgetDataValidationError(`Invalid entity type: ${request.entityType}`)
|
|
@@ -824,6 +929,7 @@ export function createWidgetDataService(
|
|
|
824
929
|
scope: WidgetDataScope,
|
|
825
930
|
registry: AnalyticsRegistry,
|
|
826
931
|
cache?: CacheStrategy,
|
|
932
|
+
baseCurrencyResolver?: BaseCurrencyResolver,
|
|
827
933
|
): WidgetDataService {
|
|
828
|
-
return new WidgetDataService({ em, scope, registry, cache })
|
|
934
|
+
return new WidgetDataService({ em, scope, registry, cache, baseCurrencyResolver })
|
|
829
935
|
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { runWithCacheTenant } from '@open-mercato/cache'
|
|
2
|
+
|
|
3
|
+
type CacheService = {
|
|
4
|
+
deleteByTags(tags: string[]): Promise<number>
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export const metadata = {
|
|
8
|
+
event: 'currencies.currency.*',
|
|
9
|
+
persistent: false,
|
|
10
|
+
id: 'dashboards:invalidate-widget-data-on-currency-change',
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export default async function handle(
|
|
14
|
+
payload: unknown,
|
|
15
|
+
context: { resolve: <T = unknown>(name: string) => T },
|
|
16
|
+
): Promise<void> {
|
|
17
|
+
const data = (payload ?? {}) as Record<string, unknown>
|
|
18
|
+
const tenantId = typeof data.tenantId === 'string' ? data.tenantId : null
|
|
19
|
+
if (!tenantId) return
|
|
20
|
+
|
|
21
|
+
let cache: CacheService
|
|
22
|
+
try {
|
|
23
|
+
cache = context.resolve<CacheService>('cache')
|
|
24
|
+
} catch {
|
|
25
|
+
return
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
await runWithCacheTenant(tenantId, () => cache.deleteByTags(['widget-data']))
|
|
30
|
+
} catch {
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import * as React from 'react'
|
|
4
4
|
import type { DashboardWidgetComponentProps } from '@open-mercato/shared/modules/dashboard/widgets'
|
|
5
5
|
import { useWidgetData, type WidgetDataFetcher } from '@open-mercato/ui/backend/dashboard/widgetData'
|
|
6
|
-
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
6
|
+
import { useLocale, useT } from '@open-mercato/shared/lib/i18n/context'
|
|
7
7
|
import { KpiCard, type KpiTrend } from '@open-mercato/ui/backend/charts'
|
|
8
8
|
import {
|
|
9
9
|
DateRangeSelect,
|
|
@@ -13,7 +13,8 @@ import {
|
|
|
13
13
|
} from '@open-mercato/ui/backend/date-range'
|
|
14
14
|
import { DEFAULT_SETTINGS, hydrateSettings, type AovKpiSettings } from './config'
|
|
15
15
|
import type { WidgetDataResponse } from '../../../services/widgetDataService'
|
|
16
|
-
import {
|
|
16
|
+
import { createCurrencyFormatters } from '../../../lib/formatters'
|
|
17
|
+
import { UnlabelledAmountNotice } from '../../../components/UnlabelledAmountNotice'
|
|
17
18
|
import { createLogger } from '@open-mercato/shared/lib/logger'
|
|
18
19
|
|
|
19
20
|
const logger = createLogger('dashboards').child({ component: 'aov-kpi' })
|
|
@@ -43,11 +44,14 @@ const AovKpiWidget: React.FC<DashboardWidgetComponentProps<AovKpiSettings>> = ({
|
|
|
43
44
|
onRefreshStateChange,
|
|
44
45
|
}) => {
|
|
45
46
|
const t = useT()
|
|
47
|
+
const locale = useLocale()
|
|
46
48
|
const hydrated = React.useMemo(() => hydrateSettings(settings), [settings])
|
|
47
49
|
const [value, setValue] = React.useState<number | null>(null)
|
|
48
50
|
const [trend, setTrend] = React.useState<KpiTrend | undefined>(undefined)
|
|
51
|
+
const [currency, setCurrency] = React.useState<string | null>(null)
|
|
49
52
|
const [loading, setLoading] = React.useState(true)
|
|
50
53
|
const [error, setError] = React.useState<string | null>(null)
|
|
54
|
+
const money = React.useMemo(() => createCurrencyFormatters(currency, '--', locale), [currency, locale])
|
|
51
55
|
|
|
52
56
|
const fetchWidgetData = useWidgetData()
|
|
53
57
|
const refresh = React.useCallback(async () => {
|
|
@@ -57,6 +61,7 @@ const AovKpiWidget: React.FC<DashboardWidgetComponentProps<AovKpiSettings>> = ({
|
|
|
57
61
|
try {
|
|
58
62
|
const data = await fetchAovData(hydrated, fetchWidgetData)
|
|
59
63
|
setValue(data.value)
|
|
64
|
+
setCurrency(data.metadata?.currency ?? null)
|
|
60
65
|
if (data.comparison) {
|
|
61
66
|
setTrend({
|
|
62
67
|
value: data.comparison.change,
|
|
@@ -114,7 +119,8 @@ const AovKpiWidget: React.FC<DashboardWidgetComponentProps<AovKpiSettings>> = ({
|
|
|
114
119
|
comparisonLabel={comparisonLabel}
|
|
115
120
|
loading={loading}
|
|
116
121
|
error={error}
|
|
117
|
-
formatValue={
|
|
122
|
+
formatValue={money.formatWithDecimals}
|
|
123
|
+
footer={<UnlabelledAmountNotice currency={currency} loading={loading} error={error} />}
|
|
118
124
|
headerAction={
|
|
119
125
|
<InlineDateRangeSelect
|
|
120
126
|
value={hydrated.dateRange}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import * as React from 'react'
|
|
4
4
|
import type { DashboardWidgetComponentProps } from '@open-mercato/shared/modules/dashboard/widgets'
|
|
5
5
|
import { useWidgetData, type WidgetDataFetcher } from '@open-mercato/ui/backend/dashboard/widgetData'
|
|
6
|
-
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
6
|
+
import { useLocale, useT } from '@open-mercato/shared/lib/i18n/context'
|
|
7
7
|
import { BarChart, type BarChartDataItem } from '@open-mercato/ui/backend/charts'
|
|
8
8
|
import {
|
|
9
9
|
DateRangeSelect,
|
|
@@ -19,7 +19,8 @@ import {
|
|
|
19
19
|
type PipelineSummarySettings,
|
|
20
20
|
} from './config'
|
|
21
21
|
import type { WidgetDataResponse } from '../../../services/widgetDataService'
|
|
22
|
-
import {
|
|
22
|
+
import { createCurrencyFormatters } from '../../../lib/formatters'
|
|
23
|
+
import { UnlabelledAmountNotice } from '../../../components/UnlabelledAmountNotice'
|
|
23
24
|
import { createLogger } from '@open-mercato/shared/lib/logger'
|
|
24
25
|
|
|
25
26
|
const logger = createLogger('dashboards').child({ component: 'pipeline-summary' })
|
|
@@ -47,10 +48,13 @@ const PipelineSummaryWidget: React.FC<DashboardWidgetComponentProps<PipelineSumm
|
|
|
47
48
|
onRefreshStateChange,
|
|
48
49
|
}) => {
|
|
49
50
|
const t = useT()
|
|
51
|
+
const locale = useLocale()
|
|
50
52
|
const hydrated = React.useMemo(() => hydrateSettings(settings), [settings])
|
|
51
53
|
const [data, setData] = React.useState<BarChartDataItem[]>([])
|
|
54
|
+
const [currency, setCurrency] = React.useState<string | null>(null)
|
|
52
55
|
const [loading, setLoading] = React.useState(true)
|
|
53
56
|
const [error, setError] = React.useState<string | null>(null)
|
|
57
|
+
const money = React.useMemo(() => createCurrencyFormatters(currency, '--', locale), [currency, locale])
|
|
54
58
|
|
|
55
59
|
const fetchWidgetData = useWidgetData()
|
|
56
60
|
const refresh = React.useCallback(async () => {
|
|
@@ -66,6 +70,7 @@ const PipelineSummaryWidget: React.FC<DashboardWidgetComponentProps<PipelineSumm
|
|
|
66
70
|
Value: item.value ?? 0,
|
|
67
71
|
}))
|
|
68
72
|
setData(chartData)
|
|
73
|
+
setCurrency(result.metadata?.currency ?? null)
|
|
69
74
|
} catch (err) {
|
|
70
75
|
logger.error('Failed to load pipeline data', { err })
|
|
71
76
|
setError(t('dashboards.analytics.widgets.pipelineSummary.error', 'Failed to load data'))
|
|
@@ -132,12 +137,13 @@ const PipelineSummaryWidget: React.FC<DashboardWidgetComponentProps<PipelineSumm
|
|
|
132
137
|
categoryLabels={{ Value: t('dashboards.analytics.labels.value', 'Value') }}
|
|
133
138
|
loading={loading}
|
|
134
139
|
error={error}
|
|
135
|
-
valueFormatter={
|
|
140
|
+
valueFormatter={money.formatCompact}
|
|
136
141
|
colors={['violet']}
|
|
137
142
|
showLegend={false}
|
|
138
143
|
emptyMessage={t('dashboards.analytics.widgets.pipelineSummary.empty', 'No deal data for this period')}
|
|
139
144
|
/>
|
|
140
145
|
</div>
|
|
146
|
+
<UnlabelledAmountNotice currency={currency} loading={loading} error={error} />
|
|
141
147
|
</div>
|
|
142
148
|
)
|
|
143
149
|
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import * as React from 'react'
|
|
4
4
|
import type { DashboardWidgetComponentProps } from '@open-mercato/shared/modules/dashboard/widgets'
|
|
5
5
|
import { useWidgetData, type WidgetDataFetcher } from '@open-mercato/ui/backend/dashboard/widgetData'
|
|
6
|
-
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
6
|
+
import { useLocale, useT } from '@open-mercato/shared/lib/i18n/context'
|
|
7
7
|
import { KpiCard, type KpiTrend } from '@open-mercato/ui/backend/charts'
|
|
8
8
|
import {
|
|
9
9
|
DateRangeSelect,
|
|
@@ -13,7 +13,8 @@ import {
|
|
|
13
13
|
} from '@open-mercato/ui/backend/date-range'
|
|
14
14
|
import { DEFAULT_SETTINGS, hydrateSettings, type RevenueKpiSettings } from './config'
|
|
15
15
|
import type { WidgetDataResponse } from '../../../services/widgetDataService'
|
|
16
|
-
import {
|
|
16
|
+
import { createCurrencyFormatters } from '../../../lib/formatters'
|
|
17
|
+
import { UnlabelledAmountNotice } from '../../../components/UnlabelledAmountNotice'
|
|
17
18
|
import { createLogger } from '@open-mercato/shared/lib/logger'
|
|
18
19
|
|
|
19
20
|
const logger = createLogger('dashboards').child({ component: 'revenue-kpi' })
|
|
@@ -43,11 +44,14 @@ const RevenueKpiWidget: React.FC<DashboardWidgetComponentProps<RevenueKpiSetting
|
|
|
43
44
|
onRefreshStateChange,
|
|
44
45
|
}) => {
|
|
45
46
|
const t = useT()
|
|
47
|
+
const locale = useLocale()
|
|
46
48
|
const hydrated = React.useMemo(() => hydrateSettings(settings), [settings])
|
|
47
49
|
const [value, setValue] = React.useState<number | null>(null)
|
|
48
50
|
const [trend, setTrend] = React.useState<KpiTrend | undefined>(undefined)
|
|
51
|
+
const [currency, setCurrency] = React.useState<string | null>(null)
|
|
49
52
|
const [loading, setLoading] = React.useState(true)
|
|
50
53
|
const [error, setError] = React.useState<string | null>(null)
|
|
54
|
+
const money = React.useMemo(() => createCurrencyFormatters(currency, '--', locale), [currency, locale])
|
|
51
55
|
|
|
52
56
|
const fetchWidgetData = useWidgetData()
|
|
53
57
|
const refresh = React.useCallback(async () => {
|
|
@@ -57,6 +61,7 @@ const RevenueKpiWidget: React.FC<DashboardWidgetComponentProps<RevenueKpiSetting
|
|
|
57
61
|
try {
|
|
58
62
|
const data = await fetchRevenueData(hydrated, fetchWidgetData)
|
|
59
63
|
setValue(data.value)
|
|
64
|
+
setCurrency(data.metadata?.currency ?? null)
|
|
60
65
|
if (data.comparison) {
|
|
61
66
|
setTrend({
|
|
62
67
|
value: data.comparison.change,
|
|
@@ -114,7 +119,8 @@ const RevenueKpiWidget: React.FC<DashboardWidgetComponentProps<RevenueKpiSetting
|
|
|
114
119
|
comparisonLabel={comparisonLabel}
|
|
115
120
|
loading={loading}
|
|
116
121
|
error={error}
|
|
117
|
-
formatValue={
|
|
122
|
+
formatValue={money.format}
|
|
123
|
+
footer={<UnlabelledAmountNotice currency={currency} loading={loading} error={error} />}
|
|
118
124
|
headerAction={
|
|
119
125
|
<InlineDateRangeSelect
|
|
120
126
|
value={hydrated.dateRange}
|
|
@@ -20,7 +20,8 @@ import {
|
|
|
20
20
|
import type { DateGranularity } from '@open-mercato/shared/modules/analytics'
|
|
21
21
|
import { DEFAULT_SETTINGS, hydrateSettings, type RevenueTrendSettings } from './config'
|
|
22
22
|
import type { WidgetDataResponse } from '../../../services/widgetDataService'
|
|
23
|
-
import {
|
|
23
|
+
import { createCurrencyFormatters } from '../../../lib/formatters'
|
|
24
|
+
import { UnlabelledAmountNotice } from '../../../components/UnlabelledAmountNotice'
|
|
24
25
|
import { createLogger } from '@open-mercato/shared/lib/logger'
|
|
25
26
|
|
|
26
27
|
const logger = createLogger('dashboards').child({ component: 'revenue-trend' })
|
|
@@ -114,8 +115,10 @@ const RevenueTrendWidget: React.FC<DashboardWidgetComponentProps<RevenueTrendSet
|
|
|
114
115
|
const locale = useLocale()
|
|
115
116
|
const hydrated = React.useMemo(() => hydrateSettings(settings), [settings])
|
|
116
117
|
const [data, setData] = React.useState<LineChartDataItem[]>([])
|
|
118
|
+
const [currency, setCurrency] = React.useState<string | null>(null)
|
|
117
119
|
const [loading, setLoading] = React.useState(true)
|
|
118
120
|
const [error, setError] = React.useState<string | null>(null)
|
|
121
|
+
const money = React.useMemo(() => createCurrencyFormatters(currency, '--', locale), [currency, locale])
|
|
119
122
|
|
|
120
123
|
const fetchWidgetData = useWidgetData()
|
|
121
124
|
const refresh = React.useCallback(async () => {
|
|
@@ -134,6 +137,7 @@ const RevenueTrendWidget: React.FC<DashboardWidgetComponentProps<RevenueTrendSet
|
|
|
134
137
|
Revenue: item.value ?? 0,
|
|
135
138
|
}))
|
|
136
139
|
setData(chartData)
|
|
140
|
+
setCurrency(result.metadata?.currency ?? null)
|
|
137
141
|
} catch (err) {
|
|
138
142
|
logger.error('Failed to load revenue trend data', { err })
|
|
139
143
|
setError(t('dashboards.analytics.widgets.revenueTrend.error', 'Failed to load data'))
|
|
@@ -212,10 +216,11 @@ const RevenueTrendWidget: React.FC<DashboardWidgetComponentProps<RevenueTrendSet
|
|
|
212
216
|
loading={loading}
|
|
213
217
|
error={error}
|
|
214
218
|
showArea={hydrated.showArea}
|
|
215
|
-
valueFormatter={
|
|
219
|
+
valueFormatter={money.formatCompact}
|
|
216
220
|
colors={['blue']}
|
|
217
221
|
emptyMessage={t('dashboards.analytics.widgets.revenueTrend.empty', 'No revenue data for this period')}
|
|
218
222
|
/>
|
|
223
|
+
<UnlabelledAmountNotice currency={currency} loading={loading} error={error} />
|
|
219
224
|
</div>
|
|
220
225
|
)
|
|
221
226
|
}
|