@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.
Files changed (100) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/attachments/api/route.js +16 -2
  3. package/dist/modules/attachments/api/route.js.map +2 -2
  4. package/dist/modules/attachments/lib/upload-limits.js +33 -0
  5. package/dist/modules/attachments/lib/upload-limits.js.map +2 -2
  6. package/dist/modules/communication_channels/api/post/test-seed/route.js +52 -2
  7. package/dist/modules/communication_channels/api/post/test-seed/route.js.map +2 -2
  8. package/dist/modules/currencies/di.js +4 -0
  9. package/dist/modules/currencies/di.js.map +2 -2
  10. package/dist/modules/currencies/services/baseCurrencyService.js +42 -0
  11. package/dist/modules/currencies/services/baseCurrencyService.js.map +7 -0
  12. package/dist/modules/customers/analytics.js +3 -1
  13. package/dist/modules/customers/analytics.js.map +2 -2
  14. package/dist/modules/customers/api/deals/aggregate/route.js +5 -4
  15. package/dist/modules/customers/api/deals/aggregate/route.js.map +2 -2
  16. package/dist/modules/customers/api/deals/summary/route.js +5 -4
  17. package/dist/modules/customers/api/deals/summary/route.js.map +2 -2
  18. package/dist/modules/customers/lib/optionalBaseCurrency.js +19 -0
  19. package/dist/modules/customers/lib/optionalBaseCurrency.js.map +7 -0
  20. package/dist/modules/dashboards/api/widgets/data/batch/route.js +8 -1
  21. package/dist/modules/dashboards/api/widgets/data/batch/route.js.map +2 -2
  22. package/dist/modules/dashboards/api/widgets/data/route.js +8 -1
  23. package/dist/modules/dashboards/api/widgets/data/route.js.map +2 -2
  24. package/dist/modules/dashboards/api/widgets/data/schema.js +2 -1
  25. package/dist/modules/dashboards/api/widgets/data/schema.js.map +2 -2
  26. package/dist/modules/dashboards/components/UnlabelledAmountNotice.js +33 -0
  27. package/dist/modules/dashboards/components/UnlabelledAmountNotice.js.map +7 -0
  28. package/dist/modules/dashboards/lib/aggregations.js +17 -0
  29. package/dist/modules/dashboards/lib/aggregations.js.map +2 -2
  30. package/dist/modules/dashboards/lib/formatters.js +84 -11
  31. package/dist/modules/dashboards/lib/formatters.js.map +2 -2
  32. package/dist/modules/dashboards/lib/optionalBaseCurrency.js +11 -0
  33. package/dist/modules/dashboards/lib/optionalBaseCurrency.js.map +7 -0
  34. package/dist/modules/dashboards/services/widgetDataService.js +86 -6
  35. package/dist/modules/dashboards/services/widgetDataService.js.map +2 -2
  36. package/dist/modules/dashboards/subscribers/invalidateWidgetDataCache.js +26 -0
  37. package/dist/modules/dashboards/subscribers/invalidateWidgetDataCache.js.map +7 -0
  38. package/dist/modules/dashboards/widgets/dashboard/aov-kpi/widget.client.js +9 -3
  39. package/dist/modules/dashboards/widgets/dashboard/aov-kpi/widget.client.js.map +2 -2
  40. package/dist/modules/dashboards/widgets/dashboard/pipeline-summary/widget.client.js +10 -4
  41. package/dist/modules/dashboards/widgets/dashboard/pipeline-summary/widget.client.js.map +2 -2
  42. package/dist/modules/dashboards/widgets/dashboard/revenue-kpi/widget.client.js +9 -3
  43. package/dist/modules/dashboards/widgets/dashboard/revenue-kpi/widget.client.js.map +2 -2
  44. package/dist/modules/dashboards/widgets/dashboard/revenue-trend/widget.client.js +8 -3
  45. package/dist/modules/dashboards/widgets/dashboard/revenue-trend/widget.client.js.map +2 -2
  46. package/dist/modules/dashboards/widgets/dashboard/sales-by-region/widget.client.js +26 -18
  47. package/dist/modules/dashboards/widgets/dashboard/sales-by-region/widget.client.js.map +2 -2
  48. package/dist/modules/dashboards/widgets/dashboard/top-customers/widget.client.js +22 -14
  49. package/dist/modules/dashboards/widgets/dashboard/top-customers/widget.client.js.map +2 -2
  50. package/dist/modules/dashboards/widgets/dashboard/top-products/widget.client.js +10 -4
  51. package/dist/modules/dashboards/widgets/dashboard/top-products/widget.client.js.map +2 -2
  52. package/dist/modules/notifications/api/route.js +62 -3
  53. package/dist/modules/notifications/api/route.js.map +2 -2
  54. package/dist/modules/sales/analytics.js +7 -3
  55. package/dist/modules/sales/analytics.js.map +2 -2
  56. package/dist/modules/sales/api/document-history/route.js +2 -1
  57. package/dist/modules/sales/api/document-history/route.js.map +2 -2
  58. package/dist/modules/sales/lib/historyHelpers.js +69 -1
  59. package/dist/modules/sales/lib/historyHelpers.js.map +2 -2
  60. package/dist/modules/sales/widgets/injection/document-history/widget.client.js +51 -2
  61. package/dist/modules/sales/widgets/injection/document-history/widget.client.js.map +2 -2
  62. package/package.json +7 -7
  63. package/src/modules/attachments/api/route.ts +16 -2
  64. package/src/modules/attachments/lib/upload-limits.ts +35 -0
  65. package/src/modules/communication_channels/api/post/test-seed/route.ts +69 -2
  66. package/src/modules/currencies/di.ts +4 -1
  67. package/src/modules/currencies/services/baseCurrencyService.ts +59 -0
  68. package/src/modules/customers/analytics.ts +2 -0
  69. package/src/modules/customers/api/deals/aggregate/route.ts +5 -4
  70. package/src/modules/customers/api/deals/summary/route.ts +5 -4
  71. package/src/modules/customers/lib/optionalBaseCurrency.ts +34 -0
  72. package/src/modules/dashboards/api/widgets/data/batch/route.ts +8 -1
  73. package/src/modules/dashboards/api/widgets/data/route.ts +8 -1
  74. package/src/modules/dashboards/api/widgets/data/schema.ts +1 -0
  75. package/src/modules/dashboards/components/UnlabelledAmountNotice.tsx +43 -0
  76. package/src/modules/dashboards/i18n/de.json +2 -0
  77. package/src/modules/dashboards/i18n/en.json +2 -0
  78. package/src/modules/dashboards/i18n/es.json +2 -0
  79. package/src/modules/dashboards/i18n/pl.json +2 -0
  80. package/src/modules/dashboards/lib/aggregations.ts +35 -0
  81. package/src/modules/dashboards/lib/formatters.ts +137 -12
  82. package/src/modules/dashboards/lib/optionalBaseCurrency.ts +22 -0
  83. package/src/modules/dashboards/services/widgetDataService.ts +110 -4
  84. package/src/modules/dashboards/subscribers/invalidateWidgetDataCache.ts +32 -0
  85. package/src/modules/dashboards/widgets/dashboard/aov-kpi/widget.client.tsx +9 -3
  86. package/src/modules/dashboards/widgets/dashboard/pipeline-summary/widget.client.tsx +9 -3
  87. package/src/modules/dashboards/widgets/dashboard/revenue-kpi/widget.client.tsx +9 -3
  88. package/src/modules/dashboards/widgets/dashboard/revenue-trend/widget.client.tsx +7 -2
  89. package/src/modules/dashboards/widgets/dashboard/sales-by-region/widget.client.tsx +25 -15
  90. package/src/modules/dashboards/widgets/dashboard/top-customers/widget.client.tsx +21 -11
  91. package/src/modules/dashboards/widgets/dashboard/top-products/widget.client.tsx +9 -3
  92. package/src/modules/notifications/api/route.ts +88 -3
  93. package/src/modules/sales/analytics.ts +4 -0
  94. package/src/modules/sales/api/document-history/route.ts +1 -0
  95. package/src/modules/sales/i18n/de.json +19 -0
  96. package/src/modules/sales/i18n/en.json +19 -0
  97. package/src/modules/sales/i18n/es.json +19 -0
  98. package/src/modules/sales/i18n/pl.json +19 -0
  99. package/src/modules/sales/lib/historyHelpers.ts +88 -1
  100. 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 function formatCurrency(value: number, options: FormatCurrencyOptions = {}): string {
8
- const { currency = 'USD', minimumFractionDigits = 0, maximumFractionDigits = 0 } = options
9
- return new Intl.NumberFormat(undefined, {
10
- style: 'currency',
11
- currency,
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
- export function formatCurrencyCompact(value: number, currencySymbol = '$'): string {
78
+ function formatWithLiteralSymbol(value: number, symbol: string): string {
22
79
  if (Math.abs(value) >= 1_000_000) {
23
- return `${currencySymbol}${(value / 1_000_000).toFixed(1)}M`
80
+ return `${symbol}${(value / 1_000_000).toFixed(1)}M`
24
81
  }
25
82
  if (Math.abs(value) >= 1_000) {
26
- return `${currencySymbol}${(value / 1_000).toFixed(1)}K`
83
+ return `${symbol}${(value / 1_000).toFixed(1)}K`
27
84
  }
28
- return `${currencySymbol}${value.toFixed(0)}`
85
+ return `${symbol}${value.toFixed(0)}`
29
86
  }
30
87
 
31
- export function formatCurrencySafe(value: unknown, fallback = '--'): string {
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 [mainResult, comparisonResult] = await Promise.all([
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 { formatCurrencyWithDecimals } from '../../../lib/formatters'
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={formatCurrencyWithDecimals}
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 { formatCurrencyCompact } from '../../../lib/formatters'
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={formatCurrencyCompact}
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 { formatCurrency } from '../../../lib/formatters'
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={formatCurrency}
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 { formatCurrencyCompact } from '../../../lib/formatters'
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={formatCurrencyCompact}
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
  }