@open-mercato/core 0.4.2-canary-10c7a8bf2a → 0.4.2-canary-e6bf6a353e

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 (136) hide show
  1. package/dist/modules/auth/lib/setup-app.js +2 -0
  2. package/dist/modules/auth/lib/setup-app.js.map +2 -2
  3. package/dist/modules/catalog/analytics.js +27 -0
  4. package/dist/modules/catalog/analytics.js.map +7 -0
  5. package/dist/modules/customers/analytics.js +50 -0
  6. package/dist/modules/customers/analytics.js.map +7 -0
  7. package/dist/modules/dashboards/acl.js +2 -1
  8. package/dist/modules/dashboards/acl.js.map +2 -2
  9. package/dist/modules/dashboards/api/widgets/data/route.js +187 -0
  10. package/dist/modules/dashboards/api/widgets/data/route.js.map +7 -0
  11. package/dist/modules/dashboards/cli.js +142 -1
  12. package/dist/modules/dashboards/cli.js.map +2 -2
  13. package/dist/modules/dashboards/di.js +11 -0
  14. package/dist/modules/dashboards/di.js.map +7 -0
  15. package/dist/modules/dashboards/lib/aggregations.js +162 -0
  16. package/dist/modules/dashboards/lib/aggregations.js.map +7 -0
  17. package/dist/modules/dashboards/lib/formatters.js +34 -0
  18. package/dist/modules/dashboards/lib/formatters.js.map +7 -0
  19. package/dist/modules/dashboards/seed/analytics.js +383 -0
  20. package/dist/modules/dashboards/seed/analytics.js.map +7 -0
  21. package/dist/modules/dashboards/services/analyticsRegistry.js +52 -0
  22. package/dist/modules/dashboards/services/analyticsRegistry.js.map +7 -0
  23. package/dist/modules/dashboards/services/widgetDataService.js +207 -0
  24. package/dist/modules/dashboards/services/widgetDataService.js.map +7 -0
  25. package/dist/modules/dashboards/widgets/dashboard/aov-kpi/config.js +18 -0
  26. package/dist/modules/dashboards/widgets/dashboard/aov-kpi/config.js.map +7 -0
  27. package/dist/modules/dashboards/widgets/dashboard/aov-kpi/widget.client.js +128 -0
  28. package/dist/modules/dashboards/widgets/dashboard/aov-kpi/widget.client.js.map +7 -0
  29. package/dist/modules/dashboards/widgets/dashboard/aov-kpi/widget.js +25 -0
  30. package/dist/modules/dashboards/widgets/dashboard/aov-kpi/widget.js.map +7 -0
  31. package/dist/modules/dashboards/widgets/dashboard/new-customers-kpi/config.js +18 -0
  32. package/dist/modules/dashboards/widgets/dashboard/new-customers-kpi/config.js.map +7 -0
  33. package/dist/modules/dashboards/widgets/dashboard/new-customers-kpi/widget.client.js +126 -0
  34. package/dist/modules/dashboards/widgets/dashboard/new-customers-kpi/widget.client.js.map +7 -0
  35. package/dist/modules/dashboards/widgets/dashboard/new-customers-kpi/widget.js +25 -0
  36. package/dist/modules/dashboards/widgets/dashboard/new-customers-kpi/widget.js.map +7 -0
  37. package/dist/modules/dashboards/widgets/dashboard/orders-by-status/config.js +18 -0
  38. package/dist/modules/dashboards/widgets/dashboard/orders-by-status/config.js.map +7 -0
  39. package/dist/modules/dashboards/widgets/dashboard/orders-by-status/widget.client.js +151 -0
  40. package/dist/modules/dashboards/widgets/dashboard/orders-by-status/widget.client.js.map +7 -0
  41. package/dist/modules/dashboards/widgets/dashboard/orders-by-status/widget.js +25 -0
  42. package/dist/modules/dashboards/widgets/dashboard/orders-by-status/widget.js.map +7 -0
  43. package/dist/modules/dashboards/widgets/dashboard/orders-kpi/config.js +18 -0
  44. package/dist/modules/dashboards/widgets/dashboard/orders-kpi/config.js.map +7 -0
  45. package/dist/modules/dashboards/widgets/dashboard/orders-kpi/widget.client.js +126 -0
  46. package/dist/modules/dashboards/widgets/dashboard/orders-kpi/widget.client.js.map +7 -0
  47. package/dist/modules/dashboards/widgets/dashboard/orders-kpi/widget.js +25 -0
  48. package/dist/modules/dashboards/widgets/dashboard/orders-kpi/widget.js.map +7 -0
  49. package/dist/modules/dashboards/widgets/dashboard/pipeline-summary/config.js +16 -0
  50. package/dist/modules/dashboards/widgets/dashboard/pipeline-summary/config.js.map +7 -0
  51. package/dist/modules/dashboards/widgets/dashboard/pipeline-summary/widget.client.js +123 -0
  52. package/dist/modules/dashboards/widgets/dashboard/pipeline-summary/widget.client.js.map +7 -0
  53. package/dist/modules/dashboards/widgets/dashboard/pipeline-summary/widget.js +25 -0
  54. package/dist/modules/dashboards/widgets/dashboard/pipeline-summary/widget.js.map +7 -0
  55. package/dist/modules/dashboards/widgets/dashboard/revenue-kpi/config.js +18 -0
  56. package/dist/modules/dashboards/widgets/dashboard/revenue-kpi/config.js.map +7 -0
  57. package/dist/modules/dashboards/widgets/dashboard/revenue-kpi/widget.client.js +128 -0
  58. package/dist/modules/dashboards/widgets/dashboard/revenue-kpi/widget.client.js.map +7 -0
  59. package/dist/modules/dashboards/widgets/dashboard/revenue-kpi/widget.js +25 -0
  60. package/dist/modules/dashboards/widgets/dashboard/revenue-kpi/widget.js.map +7 -0
  61. package/dist/modules/dashboards/widgets/dashboard/revenue-trend/config.js +21 -0
  62. package/dist/modules/dashboards/widgets/dashboard/revenue-trend/config.js.map +7 -0
  63. package/dist/modules/dashboards/widgets/dashboard/revenue-trend/widget.client.js +211 -0
  64. package/dist/modules/dashboards/widgets/dashboard/revenue-trend/widget.client.js.map +7 -0
  65. package/dist/modules/dashboards/widgets/dashboard/revenue-trend/widget.js +25 -0
  66. package/dist/modules/dashboards/widgets/dashboard/revenue-trend/widget.js.map +7 -0
  67. package/dist/modules/dashboards/widgets/dashboard/sales-by-region/config.js +19 -0
  68. package/dist/modules/dashboards/widgets/dashboard/sales-by-region/config.js.map +7 -0
  69. package/dist/modules/dashboards/widgets/dashboard/sales-by-region/widget.client.js +131 -0
  70. package/dist/modules/dashboards/widgets/dashboard/sales-by-region/widget.client.js.map +7 -0
  71. package/dist/modules/dashboards/widgets/dashboard/sales-by-region/widget.js +25 -0
  72. package/dist/modules/dashboards/widgets/dashboard/sales-by-region/widget.js.map +7 -0
  73. package/dist/modules/dashboards/widgets/dashboard/top-customers/config.js +19 -0
  74. package/dist/modules/dashboards/widgets/dashboard/top-customers/config.js.map +7 -0
  75. package/dist/modules/dashboards/widgets/dashboard/top-customers/widget.client.js +153 -0
  76. package/dist/modules/dashboards/widgets/dashboard/top-customers/widget.client.js.map +7 -0
  77. package/dist/modules/dashboards/widgets/dashboard/top-customers/widget.js +25 -0
  78. package/dist/modules/dashboards/widgets/dashboard/top-customers/widget.js.map +7 -0
  79. package/dist/modules/dashboards/widgets/dashboard/top-products/config.js +22 -0
  80. package/dist/modules/dashboards/widgets/dashboard/top-products/config.js.map +7 -0
  81. package/dist/modules/dashboards/widgets/dashboard/top-products/widget.client.js +180 -0
  82. package/dist/modules/dashboards/widgets/dashboard/top-products/widget.client.js.map +7 -0
  83. package/dist/modules/dashboards/widgets/dashboard/top-products/widget.js +25 -0
  84. package/dist/modules/dashboards/widgets/dashboard/top-products/widget.js.map +7 -0
  85. package/dist/modules/sales/analytics.js +67 -0
  86. package/dist/modules/sales/analytics.js.map +7 -0
  87. package/package.json +2 -2
  88. package/src/modules/auth/lib/setup-app.ts +2 -0
  89. package/src/modules/catalog/analytics.ts +24 -0
  90. package/src/modules/customers/analytics.ts +47 -0
  91. package/src/modules/dashboards/acl.ts +1 -0
  92. package/src/modules/dashboards/api/widgets/data/route.ts +221 -0
  93. package/src/modules/dashboards/cli.ts +164 -1
  94. package/src/modules/dashboards/di.ts +9 -0
  95. package/src/modules/dashboards/i18n/de.json +115 -1
  96. package/src/modules/dashboards/i18n/en.json +115 -1
  97. package/src/modules/dashboards/i18n/es.json +115 -1
  98. package/src/modules/dashboards/i18n/pl.json +115 -1
  99. package/src/modules/dashboards/lib/__tests__/aggregations.test.ts +327 -0
  100. package/src/modules/dashboards/lib/__tests__/formatters.test.ts +128 -0
  101. package/src/modules/dashboards/lib/aggregations.ts +225 -0
  102. package/src/modules/dashboards/lib/formatters.ts +36 -0
  103. package/src/modules/dashboards/seed/analytics.ts +405 -0
  104. package/src/modules/dashboards/services/analyticsRegistry.ts +79 -0
  105. package/src/modules/dashboards/services/widgetDataService.ts +329 -0
  106. package/src/modules/dashboards/widgets/dashboard/aov-kpi/config.ts +20 -0
  107. package/src/modules/dashboards/widgets/dashboard/aov-kpi/widget.client.tsx +135 -0
  108. package/src/modules/dashboards/widgets/dashboard/aov-kpi/widget.ts +24 -0
  109. package/src/modules/dashboards/widgets/dashboard/new-customers-kpi/config.ts +20 -0
  110. package/src/modules/dashboards/widgets/dashboard/new-customers-kpi/widget.client.tsx +133 -0
  111. package/src/modules/dashboards/widgets/dashboard/new-customers-kpi/widget.ts +24 -0
  112. package/src/modules/dashboards/widgets/dashboard/orders-by-status/config.ts +20 -0
  113. package/src/modules/dashboards/widgets/dashboard/orders-by-status/widget.client.tsx +154 -0
  114. package/src/modules/dashboards/widgets/dashboard/orders-by-status/widget.ts +24 -0
  115. package/src/modules/dashboards/widgets/dashboard/orders-kpi/config.ts +20 -0
  116. package/src/modules/dashboards/widgets/dashboard/orders-kpi/widget.client.tsx +133 -0
  117. package/src/modules/dashboards/widgets/dashboard/orders-kpi/widget.ts +24 -0
  118. package/src/modules/dashboards/widgets/dashboard/pipeline-summary/config.ts +17 -0
  119. package/src/modules/dashboards/widgets/dashboard/pipeline-summary/widget.client.tsx +137 -0
  120. package/src/modules/dashboards/widgets/dashboard/pipeline-summary/widget.ts +24 -0
  121. package/src/modules/dashboards/widgets/dashboard/revenue-kpi/config.ts +20 -0
  122. package/src/modules/dashboards/widgets/dashboard/revenue-kpi/widget.client.tsx +135 -0
  123. package/src/modules/dashboards/widgets/dashboard/revenue-kpi/widget.ts +24 -0
  124. package/src/modules/dashboards/widgets/dashboard/revenue-trend/config.ts +24 -0
  125. package/src/modules/dashboards/widgets/dashboard/revenue-trend/widget.client.tsx +220 -0
  126. package/src/modules/dashboards/widgets/dashboard/revenue-trend/widget.ts +24 -0
  127. package/src/modules/dashboards/widgets/dashboard/sales-by-region/config.ts +21 -0
  128. package/src/modules/dashboards/widgets/dashboard/sales-by-region/widget.client.tsx +131 -0
  129. package/src/modules/dashboards/widgets/dashboard/sales-by-region/widget.ts +24 -0
  130. package/src/modules/dashboards/widgets/dashboard/top-customers/config.ts +21 -0
  131. package/src/modules/dashboards/widgets/dashboard/top-customers/widget.client.tsx +161 -0
  132. package/src/modules/dashboards/widgets/dashboard/top-customers/widget.ts +24 -0
  133. package/src/modules/dashboards/widgets/dashboard/top-products/config.ts +27 -0
  134. package/src/modules/dashboards/widgets/dashboard/top-products/widget.client.tsx +181 -0
  135. package/src/modules/dashboards/widgets/dashboard/top-products/widget.ts +24 -0
  136. package/src/modules/sales/analytics.ts +64 -0
@@ -0,0 +1,329 @@
1
+ import type { EntityManager } from '@mikro-orm/postgresql'
2
+ import type { CacheStrategy } from '@open-mercato/cache'
3
+ import { createHash } from 'node:crypto'
4
+ import {
5
+ type DateRangePreset,
6
+ resolveDateRange,
7
+ getPreviousPeriod,
8
+ calculatePercentageChange,
9
+ determineChangeDirection,
10
+ isValidDateRangePreset,
11
+ } from '@open-mercato/ui/backend/date-range'
12
+ import {
13
+ type AggregateFunction,
14
+ type DateGranularity,
15
+ buildAggregationQuery,
16
+ } from '../lib/aggregations'
17
+ import type { AnalyticsRegistry } from './analyticsRegistry'
18
+
19
+ const WIDGET_DATA_CACHE_TTL = 120_000
20
+
21
+ const SAFE_IDENTIFIER_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*$/
22
+
23
+ export class WidgetDataValidationError extends Error {
24
+ constructor(message: string) {
25
+ super(message)
26
+ this.name = 'WidgetDataValidationError'
27
+ }
28
+ }
29
+
30
+ function assertSafeIdentifier(value: string, name: string): void {
31
+ if (!SAFE_IDENTIFIER_PATTERN.test(value)) {
32
+ throw new Error(`Invalid ${name}: ${value}`)
33
+ }
34
+ }
35
+
36
+ export type WidgetDataRequest = {
37
+ entityType: string
38
+ metric: {
39
+ field: string
40
+ aggregate: AggregateFunction
41
+ }
42
+ groupBy?: {
43
+ field: string
44
+ granularity?: DateGranularity
45
+ limit?: number
46
+ resolveLabels?: boolean
47
+ }
48
+ filters?: Array<{
49
+ field: string
50
+ operator: 'eq' | 'neq' | 'gt' | 'gte' | 'lt' | 'lte' | 'in' | 'not_in' | 'is_null' | 'is_not_null'
51
+ value?: unknown
52
+ }>
53
+ dateRange?: {
54
+ field: string
55
+ preset: DateRangePreset
56
+ }
57
+ comparison?: {
58
+ type: 'previous_period' | 'previous_year'
59
+ }
60
+ }
61
+
62
+ export type WidgetDataItem = {
63
+ groupKey: unknown
64
+ groupLabel?: string
65
+ value: number | null
66
+ }
67
+
68
+ export type WidgetDataResponse = {
69
+ value: number | null
70
+ data: WidgetDataItem[]
71
+ comparison?: {
72
+ value: number | null
73
+ change: number
74
+ direction: 'up' | 'down' | 'unchanged'
75
+ }
76
+ metadata: {
77
+ fetchedAt: string
78
+ recordCount: number
79
+ }
80
+ }
81
+
82
+ export type WidgetDataScope = {
83
+ tenantId: string
84
+ organizationIds?: string[]
85
+ }
86
+
87
+ export type WidgetDataServiceOptions = {
88
+ em: EntityManager
89
+ scope: WidgetDataScope
90
+ registry: AnalyticsRegistry
91
+ cache?: CacheStrategy
92
+ }
93
+
94
+ export class WidgetDataService {
95
+ private em: EntityManager
96
+ private scope: WidgetDataScope
97
+ private registry: AnalyticsRegistry
98
+ private cache?: CacheStrategy
99
+
100
+ constructor(options: WidgetDataServiceOptions) {
101
+ this.em = options.em
102
+ this.scope = options.scope
103
+ this.registry = options.registry
104
+ this.cache = options.cache
105
+ }
106
+
107
+ private buildCacheKey(request: WidgetDataRequest): string {
108
+ const hash = createHash('sha256')
109
+ hash.update(JSON.stringify({ request, scope: this.scope }))
110
+ return `widget-data:${hash.digest('hex').slice(0, 16)}`
111
+ }
112
+
113
+ private getCacheTags(entityType: string): string[] {
114
+ return ['widget-data', `widget-data:${entityType}`]
115
+ }
116
+
117
+ async fetchWidgetData(request: WidgetDataRequest): Promise<WidgetDataResponse> {
118
+ this.validateRequest(request)
119
+
120
+ if (this.cache) {
121
+ const cacheKey = this.buildCacheKey(request)
122
+ try {
123
+ const cached = await this.cache.get(cacheKey)
124
+ if (cached && typeof cached === 'object' && 'value' in (cached as object)) {
125
+ return cached as WidgetDataResponse
126
+ }
127
+ } catch {
128
+ }
129
+ }
130
+
131
+ const now = new Date()
132
+ let dateRangeResolved: { start: Date; end: Date } | undefined
133
+ let comparisonRange: { start: Date; end: Date } | undefined
134
+
135
+ if (request.dateRange) {
136
+ dateRangeResolved = resolveDateRange(request.dateRange.preset, now)
137
+ if (request.comparison) {
138
+ comparisonRange = getPreviousPeriod(dateRangeResolved, request.dateRange.preset)
139
+ }
140
+ }
141
+
142
+ const mainResult = await this.executeQuery(request, dateRangeResolved)
143
+
144
+ let comparisonResult: { value: number | null; data: WidgetDataItem[] } | undefined
145
+ if (comparisonRange && request.dateRange) {
146
+ comparisonResult = await this.executeQuery(request, comparisonRange)
147
+ }
148
+
149
+ const response: WidgetDataResponse = {
150
+ value: mainResult.value,
151
+ data: mainResult.data,
152
+ metadata: {
153
+ fetchedAt: now.toISOString(),
154
+ recordCount: mainResult.data.length || (mainResult.value !== null ? 1 : 0),
155
+ },
156
+ }
157
+
158
+ if (comparisonResult && mainResult.value !== null && comparisonResult.value !== null) {
159
+ response.comparison = {
160
+ value: comparisonResult.value,
161
+ change: calculatePercentageChange(mainResult.value, comparisonResult.value),
162
+ direction: determineChangeDirection(mainResult.value, comparisonResult.value),
163
+ }
164
+ }
165
+
166
+ if (this.cache) {
167
+ const cacheKey = this.buildCacheKey(request)
168
+ const tags = this.getCacheTags(request.entityType)
169
+ try {
170
+ await this.cache.set(cacheKey, response, { ttl: WIDGET_DATA_CACHE_TTL, tags })
171
+ } catch {
172
+ }
173
+ }
174
+
175
+ return response
176
+ }
177
+
178
+ private validateRequest(request: WidgetDataRequest): void {
179
+ if (!this.registry.isValidEntityType(request.entityType)) {
180
+ throw new WidgetDataValidationError(`Invalid entity type: ${request.entityType}`)
181
+ }
182
+
183
+ if (!request.metric?.field || !request.metric?.aggregate) {
184
+ throw new WidgetDataValidationError('Metric field and aggregate are required')
185
+ }
186
+
187
+ const metricMapping = this.registry.getFieldMapping(request.entityType, request.metric.field)
188
+ if (!metricMapping) {
189
+ throw new WidgetDataValidationError(
190
+ `Invalid metric field: ${request.metric.field} for entity type: ${request.entityType}`
191
+ )
192
+ }
193
+
194
+ const validAggregates: AggregateFunction[] = ['count', 'sum', 'avg', 'min', 'max']
195
+ if (!validAggregates.includes(request.metric.aggregate)) {
196
+ throw new WidgetDataValidationError(`Invalid aggregate function: ${request.metric.aggregate}`)
197
+ }
198
+
199
+ if (request.dateRange && !isValidDateRangePreset(request.dateRange.preset)) {
200
+ throw new WidgetDataValidationError(`Invalid date range preset: ${request.dateRange.preset}`)
201
+ }
202
+
203
+ if (request.groupBy) {
204
+ const groupMapping = this.registry.getFieldMapping(request.entityType, request.groupBy.field)
205
+ if (!groupMapping) {
206
+ const [baseField] = request.groupBy.field.split('.')
207
+ const baseMapping = this.registry.getFieldMapping(request.entityType, baseField)
208
+ if (!baseMapping || baseMapping.type !== 'jsonb') {
209
+ throw new WidgetDataValidationError(`Invalid groupBy field: ${request.groupBy.field}`)
210
+ }
211
+ }
212
+ }
213
+ }
214
+
215
+ private async executeQuery(
216
+ request: WidgetDataRequest,
217
+ dateRange?: { start: Date; end: Date },
218
+ ): Promise<{ value: number | null; data: WidgetDataItem[] }> {
219
+ const query = buildAggregationQuery({
220
+ entityType: request.entityType,
221
+ metric: request.metric,
222
+ groupBy: request.groupBy,
223
+ dateRange: dateRange && request.dateRange ? { field: request.dateRange.field, ...dateRange } : undefined,
224
+ filters: request.filters,
225
+ scope: this.scope,
226
+ registry: this.registry,
227
+ })
228
+
229
+ if (!query) {
230
+ throw new Error('Failed to build aggregation query')
231
+ }
232
+
233
+ const rows = await this.em.getConnection().execute(query.sql, query.params)
234
+ const results = Array.isArray(rows) ? rows : []
235
+
236
+ if (request.groupBy) {
237
+ let data: WidgetDataItem[] = results.map((row: Record<string, unknown>) => ({
238
+ groupKey: row.group_key,
239
+ value: row.value !== null ? Number(row.value) : null,
240
+ }))
241
+
242
+ if (request.groupBy.resolveLabels) {
243
+ data = await this.resolveGroupLabels(data, request.entityType, request.groupBy.field)
244
+ }
245
+
246
+ const totalValue = data.reduce((sum: number, item: WidgetDataItem) => sum + (item.value ?? 0), 0)
247
+ return { value: totalValue, data }
248
+ }
249
+
250
+ const singleValue = results[0]?.value !== undefined ? Number(results[0].value) : null
251
+ return { value: singleValue, data: [] }
252
+ }
253
+
254
+ private async resolveGroupLabels(
255
+ data: WidgetDataItem[],
256
+ entityType: string,
257
+ groupByField: string,
258
+ ): Promise<WidgetDataItem[]> {
259
+ const config = this.registry.getLabelResolverConfig(entityType, groupByField)
260
+
261
+ if (!config) {
262
+ return data.map((item) => ({
263
+ ...item,
264
+ groupLabel: item.groupKey != null && item.groupKey !== '' ? String(item.groupKey) : undefined,
265
+ }))
266
+ }
267
+
268
+ const ids = data
269
+ .map((item) => item.groupKey)
270
+ .filter((id): id is string => {
271
+ if (typeof id !== 'string' || id.length === 0) return false
272
+ return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id)
273
+ })
274
+
275
+ if (ids.length === 0) {
276
+ return data.map((item) => ({ ...item, groupLabel: undefined }))
277
+ }
278
+
279
+ const uniqueIds = [...new Set(ids)]
280
+
281
+ assertSafeIdentifier(config.table, 'table name')
282
+ assertSafeIdentifier(config.idColumn, 'id column')
283
+ assertSafeIdentifier(config.labelColumn, 'label column')
284
+
285
+ const clauses = [`"${config.idColumn}" = ANY(?::uuid[])`, 'tenant_id = ?']
286
+ const params: unknown[] = [`{${uniqueIds.join(',')}}`, this.scope.tenantId]
287
+
288
+ if (this.scope.organizationIds && this.scope.organizationIds.length > 0) {
289
+ clauses.push('organization_id = ANY(?::uuid[])')
290
+ params.push(`{${this.scope.organizationIds.join(',')}}`)
291
+ }
292
+
293
+ const sql = `SELECT "${config.idColumn}" as id, "${config.labelColumn}" as label FROM "${config.table}" WHERE ${clauses.join(
294
+ ' AND ',
295
+ )}`
296
+
297
+ try {
298
+ const labelRows = await this.em.getConnection().execute(sql, params)
299
+
300
+ const labelMap = new Map<string, string>()
301
+ for (const row of labelRows as Array<{ id: string; label: string | null }>) {
302
+ if (row.id && row.label != null && row.label !== '') {
303
+ labelMap.set(row.id, row.label)
304
+ }
305
+ }
306
+
307
+ return data.map((item) => ({
308
+ ...item,
309
+ groupLabel: typeof item.groupKey === 'string' && labelMap.has(item.groupKey)
310
+ ? labelMap.get(item.groupKey)!
311
+ : undefined,
312
+ }))
313
+ } catch {
314
+ return data.map((item) => ({
315
+ ...item,
316
+ groupLabel: undefined,
317
+ }))
318
+ }
319
+ }
320
+ }
321
+
322
+ export function createWidgetDataService(
323
+ em: EntityManager,
324
+ scope: WidgetDataScope,
325
+ registry: AnalyticsRegistry,
326
+ cache?: CacheStrategy,
327
+ ): WidgetDataService {
328
+ return new WidgetDataService({ em, scope, registry, cache })
329
+ }
@@ -0,0 +1,20 @@
1
+ import { type DateRangePreset, isValidDateRangePreset } from '@open-mercato/ui/backend/date-range'
2
+
3
+ export type AovKpiSettings = {
4
+ dateRange: DateRangePreset
5
+ showComparison: boolean
6
+ }
7
+
8
+ export const DEFAULT_SETTINGS: AovKpiSettings = {
9
+ dateRange: 'this_month',
10
+ showComparison: true,
11
+ }
12
+
13
+ export function hydrateSettings(raw: unknown): AovKpiSettings {
14
+ if (!raw || typeof raw !== 'object') return { ...DEFAULT_SETTINGS }
15
+ const obj = raw as Record<string, unknown>
16
+ return {
17
+ dateRange: isValidDateRangePreset(obj.dateRange) ? obj.dateRange : DEFAULT_SETTINGS.dateRange,
18
+ showComparison: typeof obj.showComparison === 'boolean' ? obj.showComparison : DEFAULT_SETTINGS.showComparison,
19
+ }
20
+ }
@@ -0,0 +1,135 @@
1
+ "use client"
2
+
3
+ import * as React from 'react'
4
+ import type { DashboardWidgetComponentProps } from '@open-mercato/shared/modules/dashboard/widgets'
5
+ import { apiCall } from '@open-mercato/ui/backend/utils/apiCall'
6
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
7
+ import { KpiCard, type KpiTrend } from '@open-mercato/ui/backend/charts'
8
+ import {
9
+ DateRangeSelect,
10
+ InlineDateRangeSelect,
11
+ type DateRangePreset,
12
+ getComparisonLabelKey,
13
+ } from '@open-mercato/ui/backend/date-range'
14
+ import { DEFAULT_SETTINGS, hydrateSettings, type AovKpiSettings } from './config'
15
+ import type { WidgetDataResponse } from '../../../services/widgetDataService'
16
+ import { formatCurrencyWithDecimals } from '../../../lib/formatters'
17
+
18
+ async function fetchAovData(settings: AovKpiSettings): Promise<WidgetDataResponse> {
19
+ const body = {
20
+ entityType: 'sales:orders',
21
+ metric: {
22
+ field: 'grandTotalGrossAmount',
23
+ aggregate: 'avg',
24
+ },
25
+ dateRange: {
26
+ field: 'placedAt',
27
+ preset: settings.dateRange,
28
+ },
29
+ comparison: settings.showComparison ? { type: 'previous_period' } : undefined,
30
+ }
31
+
32
+ const call = await apiCall<WidgetDataResponse>('/api/dashboards/widgets/data', {
33
+ method: 'POST',
34
+ headers: { 'Content-Type': 'application/json' },
35
+ body: JSON.stringify(body),
36
+ })
37
+
38
+ if (!call.ok) {
39
+ const errorMsg = (call.result as Record<string, unknown>)?.error
40
+ throw new Error(typeof errorMsg === 'string' ? errorMsg : 'Failed to fetch AOV data')
41
+ }
42
+
43
+ return call.result as WidgetDataResponse
44
+ }
45
+
46
+ const AovKpiWidget: React.FC<DashboardWidgetComponentProps<AovKpiSettings>> = ({
47
+ mode,
48
+ settings = DEFAULT_SETTINGS,
49
+ onSettingsChange,
50
+ refreshToken,
51
+ onRefreshStateChange,
52
+ }) => {
53
+ const t = useT()
54
+ const hydrated = React.useMemo(() => hydrateSettings(settings), [settings])
55
+ const [value, setValue] = React.useState<number | null>(null)
56
+ const [trend, setTrend] = React.useState<KpiTrend | undefined>(undefined)
57
+ const [loading, setLoading] = React.useState(true)
58
+ const [error, setError] = React.useState<string | null>(null)
59
+
60
+ const refresh = React.useCallback(async () => {
61
+ onRefreshStateChange?.(true)
62
+ setLoading(true)
63
+ setError(null)
64
+ try {
65
+ const data = await fetchAovData(hydrated)
66
+ setValue(data.value)
67
+ if (data.comparison) {
68
+ setTrend({
69
+ value: data.comparison.change,
70
+ direction: data.comparison.direction,
71
+ })
72
+ } else {
73
+ setTrend(undefined)
74
+ }
75
+ } catch (err) {
76
+ console.error('Failed to load AOV KPI data', err)
77
+ setError(t('dashboards.analytics.widgets.aovKpi.error', 'Failed to load data'))
78
+ } finally {
79
+ setLoading(false)
80
+ onRefreshStateChange?.(false)
81
+ }
82
+ }, [hydrated, onRefreshStateChange, t])
83
+
84
+ React.useEffect(() => {
85
+ refresh().catch(() => {})
86
+ }, [refresh, refreshToken])
87
+
88
+ if (mode === 'settings') {
89
+ return (
90
+ <div className="space-y-4 text-sm">
91
+ <DateRangeSelect
92
+ id="aov-kpi-date-range"
93
+ label={t('dashboards.analytics.settings.dateRange', 'Date Range')}
94
+ value={hydrated.dateRange}
95
+ onChange={(dateRange: DateRangePreset) => onSettingsChange({ ...hydrated, dateRange })}
96
+ />
97
+ <div className="space-y-1.5">
98
+ <label className="flex items-center gap-2 text-sm">
99
+ <input
100
+ type="checkbox"
101
+ checked={hydrated.showComparison}
102
+ onChange={(e) => onSettingsChange({ ...hydrated, showComparison: e.target.checked })}
103
+ className="h-4 w-4 rounded border focus:ring-primary"
104
+ />
105
+ {t('dashboards.analytics.settings.showComparison', 'Show comparison')}
106
+ </label>
107
+ </div>
108
+ </div>
109
+ )
110
+ }
111
+
112
+ const comparisonLabelInfo = getComparisonLabelKey(hydrated.dateRange)
113
+ const comparisonLabel = hydrated.showComparison
114
+ ? t(comparisonLabelInfo.key, comparisonLabelInfo.fallback)
115
+ : undefined
116
+
117
+ return (
118
+ <KpiCard
119
+ value={value}
120
+ trend={trend}
121
+ comparisonLabel={comparisonLabel}
122
+ loading={loading}
123
+ error={error}
124
+ formatValue={formatCurrencyWithDecimals}
125
+ headerAction={
126
+ <InlineDateRangeSelect
127
+ value={hydrated.dateRange}
128
+ onChange={(dateRange) => onSettingsChange({ ...hydrated, dateRange })}
129
+ />
130
+ }
131
+ />
132
+ )
133
+ }
134
+
135
+ export default AovKpiWidget
@@ -0,0 +1,24 @@
1
+ import type { DashboardWidgetModule } from '@open-mercato/shared/modules/dashboard/widgets'
2
+ import AovKpiWidget from './widget.client'
3
+ import { DEFAULT_SETTINGS, hydrateSettings, type AovKpiSettings } from './config'
4
+
5
+ const widget: DashboardWidgetModule<AovKpiSettings> = {
6
+ metadata: {
7
+ id: 'dashboards.analytics.aovKpi',
8
+ title: 'Average Order Value',
9
+ description: 'Average order value with period comparison',
10
+ features: ['analytics.view', 'sales.orders.view'],
11
+ defaultSize: 'sm',
12
+ defaultEnabled: false,
13
+ defaultSettings: DEFAULT_SETTINGS,
14
+ tags: ['analytics', 'sales', 'kpi'],
15
+ category: 'analytics',
16
+ icon: 'trending-up',
17
+ supportsRefresh: true,
18
+ },
19
+ Widget: AovKpiWidget,
20
+ hydrateSettings,
21
+ dehydrateSettings: (s) => ({ dateRange: s.dateRange, showComparison: s.showComparison }),
22
+ }
23
+
24
+ export default widget
@@ -0,0 +1,20 @@
1
+ import { type DateRangePreset, isValidDateRangePreset } from '@open-mercato/ui/backend/date-range'
2
+
3
+ export type NewCustomersKpiSettings = {
4
+ dateRange: DateRangePreset
5
+ showComparison: boolean
6
+ }
7
+
8
+ export const DEFAULT_SETTINGS: NewCustomersKpiSettings = {
9
+ dateRange: 'this_month',
10
+ showComparison: true,
11
+ }
12
+
13
+ export function hydrateSettings(raw: unknown): NewCustomersKpiSettings {
14
+ if (!raw || typeof raw !== 'object') return { ...DEFAULT_SETTINGS }
15
+ const obj = raw as Record<string, unknown>
16
+ return {
17
+ dateRange: isValidDateRangePreset(obj.dateRange) ? obj.dateRange : DEFAULT_SETTINGS.dateRange,
18
+ showComparison: typeof obj.showComparison === 'boolean' ? obj.showComparison : DEFAULT_SETTINGS.showComparison,
19
+ }
20
+ }
@@ -0,0 +1,133 @@
1
+ "use client"
2
+
3
+ import * as React from 'react'
4
+ import type { DashboardWidgetComponentProps } from '@open-mercato/shared/modules/dashboard/widgets'
5
+ import { apiCall } from '@open-mercato/ui/backend/utils/apiCall'
6
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
7
+ import { KpiCard, type KpiTrend } from '@open-mercato/ui/backend/charts'
8
+ import {
9
+ DateRangeSelect,
10
+ InlineDateRangeSelect,
11
+ type DateRangePreset,
12
+ getComparisonLabelKey,
13
+ } from '@open-mercato/ui/backend/date-range'
14
+ import { DEFAULT_SETTINGS, hydrateSettings, type NewCustomersKpiSettings } from './config'
15
+ import type { WidgetDataResponse } from '../../../services/widgetDataService'
16
+
17
+ async function fetchNewCustomersData(settings: NewCustomersKpiSettings): Promise<WidgetDataResponse> {
18
+ const body = {
19
+ entityType: 'customers:entities',
20
+ metric: {
21
+ field: 'id',
22
+ aggregate: 'count',
23
+ },
24
+ dateRange: {
25
+ field: 'createdAt',
26
+ preset: settings.dateRange,
27
+ },
28
+ comparison: settings.showComparison ? { type: 'previous_period' } : undefined,
29
+ }
30
+
31
+ const call = await apiCall<WidgetDataResponse>('/api/dashboards/widgets/data', {
32
+ method: 'POST',
33
+ headers: { 'Content-Type': 'application/json' },
34
+ body: JSON.stringify(body),
35
+ })
36
+
37
+ if (!call.ok) {
38
+ const errorMsg = (call.result as Record<string, unknown>)?.error
39
+ throw new Error(typeof errorMsg === 'string' ? errorMsg : 'Failed to fetch new customers data')
40
+ }
41
+
42
+ return call.result as WidgetDataResponse
43
+ }
44
+
45
+ const NewCustomersKpiWidget: React.FC<DashboardWidgetComponentProps<NewCustomersKpiSettings>> = ({
46
+ mode,
47
+ settings = DEFAULT_SETTINGS,
48
+ onSettingsChange,
49
+ refreshToken,
50
+ onRefreshStateChange,
51
+ }) => {
52
+ const t = useT()
53
+ const hydrated = React.useMemo(() => hydrateSettings(settings), [settings])
54
+ const [value, setValue] = React.useState<number | null>(null)
55
+ const [trend, setTrend] = React.useState<KpiTrend | undefined>(undefined)
56
+ const [loading, setLoading] = React.useState(true)
57
+ const [error, setError] = React.useState<string | null>(null)
58
+
59
+ const refresh = React.useCallback(async () => {
60
+ onRefreshStateChange?.(true)
61
+ setLoading(true)
62
+ setError(null)
63
+ try {
64
+ const data = await fetchNewCustomersData(hydrated)
65
+ setValue(data.value)
66
+ if (data.comparison) {
67
+ setTrend({
68
+ value: data.comparison.change,
69
+ direction: data.comparison.direction,
70
+ })
71
+ } else {
72
+ setTrend(undefined)
73
+ }
74
+ } catch (err) {
75
+ console.error('Failed to load new customers KPI data', err)
76
+ setError(t('dashboards.analytics.widgets.newCustomersKpi.error', 'Failed to load data'))
77
+ } finally {
78
+ setLoading(false)
79
+ onRefreshStateChange?.(false)
80
+ }
81
+ }, [hydrated, onRefreshStateChange, t])
82
+
83
+ React.useEffect(() => {
84
+ refresh().catch(() => {})
85
+ }, [refresh, refreshToken])
86
+
87
+ if (mode === 'settings') {
88
+ return (
89
+ <div className="space-y-4 text-sm">
90
+ <DateRangeSelect
91
+ id="new-customers-kpi-date-range"
92
+ label={t('dashboards.analytics.settings.dateRange', 'Date Range')}
93
+ value={hydrated.dateRange}
94
+ onChange={(dateRange: DateRangePreset) => onSettingsChange({ ...hydrated, dateRange })}
95
+ />
96
+ <div className="space-y-1.5">
97
+ <label className="flex items-center gap-2 text-sm">
98
+ <input
99
+ type="checkbox"
100
+ checked={hydrated.showComparison}
101
+ onChange={(e) => onSettingsChange({ ...hydrated, showComparison: e.target.checked })}
102
+ className="h-4 w-4 rounded border focus:ring-primary"
103
+ />
104
+ {t('dashboards.analytics.settings.showComparison', 'Show comparison')}
105
+ </label>
106
+ </div>
107
+ </div>
108
+ )
109
+ }
110
+
111
+ const comparisonLabelInfo = getComparisonLabelKey(hydrated.dateRange)
112
+ const comparisonLabel = hydrated.showComparison
113
+ ? t(comparisonLabelInfo.key, comparisonLabelInfo.fallback)
114
+ : undefined
115
+
116
+ return (
117
+ <KpiCard
118
+ value={value}
119
+ trend={trend}
120
+ comparisonLabel={comparisonLabel}
121
+ loading={loading}
122
+ error={error}
123
+ headerAction={
124
+ <InlineDateRangeSelect
125
+ value={hydrated.dateRange}
126
+ onChange={(dateRange) => onSettingsChange({ ...hydrated, dateRange })}
127
+ />
128
+ }
129
+ />
130
+ )
131
+ }
132
+
133
+ export default NewCustomersKpiWidget
@@ -0,0 +1,24 @@
1
+ import type { DashboardWidgetModule } from '@open-mercato/shared/modules/dashboard/widgets'
2
+ import NewCustomersKpiWidget from './widget.client'
3
+ import { DEFAULT_SETTINGS, hydrateSettings, type NewCustomersKpiSettings } from './config'
4
+
5
+ const widget: DashboardWidgetModule<NewCustomersKpiSettings> = {
6
+ metadata: {
7
+ id: 'dashboards.analytics.newCustomersKpi',
8
+ title: 'Customer Growth',
9
+ description: 'New customer count with period comparison',
10
+ features: ['analytics.view', 'customers.people.view'],
11
+ defaultSize: 'sm',
12
+ defaultEnabled: false,
13
+ defaultSettings: DEFAULT_SETTINGS,
14
+ tags: ['analytics', 'customers', 'kpi'],
15
+ category: 'analytics',
16
+ icon: 'user-plus',
17
+ supportsRefresh: true,
18
+ },
19
+ Widget: NewCustomersKpiWidget,
20
+ hydrateSettings,
21
+ dehydrateSettings: (s) => ({ dateRange: s.dateRange, showComparison: s.showComparison }),
22
+ }
23
+
24
+ export default widget