@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.
- package/dist/modules/auth/lib/setup-app.js +2 -0
- package/dist/modules/auth/lib/setup-app.js.map +2 -2
- package/dist/modules/catalog/analytics.js +27 -0
- package/dist/modules/catalog/analytics.js.map +7 -0
- package/dist/modules/customers/analytics.js +50 -0
- package/dist/modules/customers/analytics.js.map +7 -0
- package/dist/modules/dashboards/acl.js +2 -1
- package/dist/modules/dashboards/acl.js.map +2 -2
- package/dist/modules/dashboards/api/widgets/data/route.js +187 -0
- package/dist/modules/dashboards/api/widgets/data/route.js.map +7 -0
- package/dist/modules/dashboards/cli.js +142 -1
- package/dist/modules/dashboards/cli.js.map +2 -2
- package/dist/modules/dashboards/di.js +11 -0
- package/dist/modules/dashboards/di.js.map +7 -0
- package/dist/modules/dashboards/lib/aggregations.js +162 -0
- package/dist/modules/dashboards/lib/aggregations.js.map +7 -0
- package/dist/modules/dashboards/lib/formatters.js +34 -0
- package/dist/modules/dashboards/lib/formatters.js.map +7 -0
- package/dist/modules/dashboards/seed/analytics.js +383 -0
- package/dist/modules/dashboards/seed/analytics.js.map +7 -0
- package/dist/modules/dashboards/services/analyticsRegistry.js +52 -0
- package/dist/modules/dashboards/services/analyticsRegistry.js.map +7 -0
- package/dist/modules/dashboards/services/widgetDataService.js +207 -0
- package/dist/modules/dashboards/services/widgetDataService.js.map +7 -0
- package/dist/modules/dashboards/widgets/dashboard/aov-kpi/config.js +18 -0
- package/dist/modules/dashboards/widgets/dashboard/aov-kpi/config.js.map +7 -0
- package/dist/modules/dashboards/widgets/dashboard/aov-kpi/widget.client.js +128 -0
- package/dist/modules/dashboards/widgets/dashboard/aov-kpi/widget.client.js.map +7 -0
- package/dist/modules/dashboards/widgets/dashboard/aov-kpi/widget.js +25 -0
- package/dist/modules/dashboards/widgets/dashboard/aov-kpi/widget.js.map +7 -0
- package/dist/modules/dashboards/widgets/dashboard/new-customers-kpi/config.js +18 -0
- package/dist/modules/dashboards/widgets/dashboard/new-customers-kpi/config.js.map +7 -0
- package/dist/modules/dashboards/widgets/dashboard/new-customers-kpi/widget.client.js +126 -0
- package/dist/modules/dashboards/widgets/dashboard/new-customers-kpi/widget.client.js.map +7 -0
- package/dist/modules/dashboards/widgets/dashboard/new-customers-kpi/widget.js +25 -0
- package/dist/modules/dashboards/widgets/dashboard/new-customers-kpi/widget.js.map +7 -0
- package/dist/modules/dashboards/widgets/dashboard/orders-by-status/config.js +18 -0
- package/dist/modules/dashboards/widgets/dashboard/orders-by-status/config.js.map +7 -0
- package/dist/modules/dashboards/widgets/dashboard/orders-by-status/widget.client.js +151 -0
- package/dist/modules/dashboards/widgets/dashboard/orders-by-status/widget.client.js.map +7 -0
- package/dist/modules/dashboards/widgets/dashboard/orders-by-status/widget.js +25 -0
- package/dist/modules/dashboards/widgets/dashboard/orders-by-status/widget.js.map +7 -0
- package/dist/modules/dashboards/widgets/dashboard/orders-kpi/config.js +18 -0
- package/dist/modules/dashboards/widgets/dashboard/orders-kpi/config.js.map +7 -0
- package/dist/modules/dashboards/widgets/dashboard/orders-kpi/widget.client.js +126 -0
- package/dist/modules/dashboards/widgets/dashboard/orders-kpi/widget.client.js.map +7 -0
- package/dist/modules/dashboards/widgets/dashboard/orders-kpi/widget.js +25 -0
- package/dist/modules/dashboards/widgets/dashboard/orders-kpi/widget.js.map +7 -0
- package/dist/modules/dashboards/widgets/dashboard/pipeline-summary/config.js +16 -0
- package/dist/modules/dashboards/widgets/dashboard/pipeline-summary/config.js.map +7 -0
- package/dist/modules/dashboards/widgets/dashboard/pipeline-summary/widget.client.js +123 -0
- package/dist/modules/dashboards/widgets/dashboard/pipeline-summary/widget.client.js.map +7 -0
- package/dist/modules/dashboards/widgets/dashboard/pipeline-summary/widget.js +25 -0
- package/dist/modules/dashboards/widgets/dashboard/pipeline-summary/widget.js.map +7 -0
- package/dist/modules/dashboards/widgets/dashboard/revenue-kpi/config.js +18 -0
- package/dist/modules/dashboards/widgets/dashboard/revenue-kpi/config.js.map +7 -0
- package/dist/modules/dashboards/widgets/dashboard/revenue-kpi/widget.client.js +128 -0
- package/dist/modules/dashboards/widgets/dashboard/revenue-kpi/widget.client.js.map +7 -0
- package/dist/modules/dashboards/widgets/dashboard/revenue-kpi/widget.js +25 -0
- package/dist/modules/dashboards/widgets/dashboard/revenue-kpi/widget.js.map +7 -0
- package/dist/modules/dashboards/widgets/dashboard/revenue-trend/config.js +21 -0
- package/dist/modules/dashboards/widgets/dashboard/revenue-trend/config.js.map +7 -0
- package/dist/modules/dashboards/widgets/dashboard/revenue-trend/widget.client.js +211 -0
- package/dist/modules/dashboards/widgets/dashboard/revenue-trend/widget.client.js.map +7 -0
- package/dist/modules/dashboards/widgets/dashboard/revenue-trend/widget.js +25 -0
- package/dist/modules/dashboards/widgets/dashboard/revenue-trend/widget.js.map +7 -0
- package/dist/modules/dashboards/widgets/dashboard/sales-by-region/config.js +19 -0
- package/dist/modules/dashboards/widgets/dashboard/sales-by-region/config.js.map +7 -0
- package/dist/modules/dashboards/widgets/dashboard/sales-by-region/widget.client.js +131 -0
- package/dist/modules/dashboards/widgets/dashboard/sales-by-region/widget.client.js.map +7 -0
- package/dist/modules/dashboards/widgets/dashboard/sales-by-region/widget.js +25 -0
- package/dist/modules/dashboards/widgets/dashboard/sales-by-region/widget.js.map +7 -0
- package/dist/modules/dashboards/widgets/dashboard/top-customers/config.js +19 -0
- package/dist/modules/dashboards/widgets/dashboard/top-customers/config.js.map +7 -0
- package/dist/modules/dashboards/widgets/dashboard/top-customers/widget.client.js +153 -0
- package/dist/modules/dashboards/widgets/dashboard/top-customers/widget.client.js.map +7 -0
- package/dist/modules/dashboards/widgets/dashboard/top-customers/widget.js +25 -0
- package/dist/modules/dashboards/widgets/dashboard/top-customers/widget.js.map +7 -0
- package/dist/modules/dashboards/widgets/dashboard/top-products/config.js +22 -0
- package/dist/modules/dashboards/widgets/dashboard/top-products/config.js.map +7 -0
- package/dist/modules/dashboards/widgets/dashboard/top-products/widget.client.js +180 -0
- package/dist/modules/dashboards/widgets/dashboard/top-products/widget.client.js.map +7 -0
- package/dist/modules/dashboards/widgets/dashboard/top-products/widget.js +25 -0
- package/dist/modules/dashboards/widgets/dashboard/top-products/widget.js.map +7 -0
- package/dist/modules/sales/analytics.js +67 -0
- package/dist/modules/sales/analytics.js.map +7 -0
- package/package.json +2 -2
- package/src/modules/auth/lib/setup-app.ts +2 -0
- package/src/modules/catalog/analytics.ts +24 -0
- package/src/modules/customers/analytics.ts +47 -0
- package/src/modules/dashboards/acl.ts +1 -0
- package/src/modules/dashboards/api/widgets/data/route.ts +221 -0
- package/src/modules/dashboards/cli.ts +164 -1
- package/src/modules/dashboards/di.ts +9 -0
- package/src/modules/dashboards/i18n/de.json +115 -1
- package/src/modules/dashboards/i18n/en.json +115 -1
- package/src/modules/dashboards/i18n/es.json +115 -1
- package/src/modules/dashboards/i18n/pl.json +115 -1
- package/src/modules/dashboards/lib/__tests__/aggregations.test.ts +327 -0
- package/src/modules/dashboards/lib/__tests__/formatters.test.ts +128 -0
- package/src/modules/dashboards/lib/aggregations.ts +225 -0
- package/src/modules/dashboards/lib/formatters.ts +36 -0
- package/src/modules/dashboards/seed/analytics.ts +405 -0
- package/src/modules/dashboards/services/analyticsRegistry.ts +79 -0
- package/src/modules/dashboards/services/widgetDataService.ts +329 -0
- package/src/modules/dashboards/widgets/dashboard/aov-kpi/config.ts +20 -0
- package/src/modules/dashboards/widgets/dashboard/aov-kpi/widget.client.tsx +135 -0
- package/src/modules/dashboards/widgets/dashboard/aov-kpi/widget.ts +24 -0
- package/src/modules/dashboards/widgets/dashboard/new-customers-kpi/config.ts +20 -0
- package/src/modules/dashboards/widgets/dashboard/new-customers-kpi/widget.client.tsx +133 -0
- package/src/modules/dashboards/widgets/dashboard/new-customers-kpi/widget.ts +24 -0
- package/src/modules/dashboards/widgets/dashboard/orders-by-status/config.ts +20 -0
- package/src/modules/dashboards/widgets/dashboard/orders-by-status/widget.client.tsx +154 -0
- package/src/modules/dashboards/widgets/dashboard/orders-by-status/widget.ts +24 -0
- package/src/modules/dashboards/widgets/dashboard/orders-kpi/config.ts +20 -0
- package/src/modules/dashboards/widgets/dashboard/orders-kpi/widget.client.tsx +133 -0
- package/src/modules/dashboards/widgets/dashboard/orders-kpi/widget.ts +24 -0
- package/src/modules/dashboards/widgets/dashboard/pipeline-summary/config.ts +17 -0
- package/src/modules/dashboards/widgets/dashboard/pipeline-summary/widget.client.tsx +137 -0
- package/src/modules/dashboards/widgets/dashboard/pipeline-summary/widget.ts +24 -0
- package/src/modules/dashboards/widgets/dashboard/revenue-kpi/config.ts +20 -0
- package/src/modules/dashboards/widgets/dashboard/revenue-kpi/widget.client.tsx +135 -0
- package/src/modules/dashboards/widgets/dashboard/revenue-kpi/widget.ts +24 -0
- package/src/modules/dashboards/widgets/dashboard/revenue-trend/config.ts +24 -0
- package/src/modules/dashboards/widgets/dashboard/revenue-trend/widget.client.tsx +220 -0
- package/src/modules/dashboards/widgets/dashboard/revenue-trend/widget.ts +24 -0
- package/src/modules/dashboards/widgets/dashboard/sales-by-region/config.ts +21 -0
- package/src/modules/dashboards/widgets/dashboard/sales-by-region/widget.client.tsx +131 -0
- package/src/modules/dashboards/widgets/dashboard/sales-by-region/widget.ts +24 -0
- package/src/modules/dashboards/widgets/dashboard/top-customers/config.ts +21 -0
- package/src/modules/dashboards/widgets/dashboard/top-customers/widget.client.tsx +161 -0
- package/src/modules/dashboards/widgets/dashboard/top-customers/widget.ts +24 -0
- package/src/modules/dashboards/widgets/dashboard/top-products/config.ts +27 -0
- package/src/modules/dashboards/widgets/dashboard/top-products/widget.client.tsx +181 -0
- package/src/modules/dashboards/widgets/dashboard/top-products/widget.ts +24 -0
- 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
|