@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,161 @@
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 { TopNTable, type TopNTableColumn } from '@open-mercato/ui/backend/charts'
8
+ import { DateRangeSelect, type DateRangePreset } from '@open-mercato/ui/backend/date-range'
9
+ import { DEFAULT_SETTINGS, hydrateSettings, type TopCustomersSettings } from './config'
10
+ import type { WidgetDataResponse } from '../../../services/widgetDataService'
11
+ import { formatCurrencySafe } from '../../../lib/formatters'
12
+
13
+ type CustomerRow = {
14
+ rank: number
15
+ customerId: string
16
+ revenue: number
17
+ }
18
+
19
+ async function fetchTopCustomersData(settings: TopCustomersSettings): Promise<WidgetDataResponse> {
20
+ const body = {
21
+ entityType: 'sales:orders',
22
+ metric: {
23
+ field: 'grandTotalGrossAmount',
24
+ aggregate: 'sum',
25
+ },
26
+ groupBy: {
27
+ field: 'customerEntityId',
28
+ limit: settings.limit,
29
+ resolveLabels: true,
30
+ },
31
+ dateRange: {
32
+ field: 'placedAt',
33
+ preset: settings.dateRange,
34
+ },
35
+ }
36
+
37
+ const call = await apiCall<WidgetDataResponse>('/api/dashboards/widgets/data', {
38
+ method: 'POST',
39
+ headers: { 'Content-Type': 'application/json' },
40
+ body: JSON.stringify(body),
41
+ })
42
+
43
+ if (!call.ok) {
44
+ const errorMsg = (call.result as Record<string, unknown>)?.error
45
+ throw new Error(typeof errorMsg === 'string' ? errorMsg : 'Failed to fetch top customers data')
46
+ }
47
+
48
+ return call.result as WidgetDataResponse
49
+ }
50
+
51
+ function formatCustomerName(name: string | null, unknownLabel: string): string {
52
+ if (!name) return unknownLabel
53
+ return name
54
+ }
55
+
56
+ const TopCustomersWidget: React.FC<DashboardWidgetComponentProps<TopCustomersSettings>> = ({
57
+ mode,
58
+ settings = DEFAULT_SETTINGS,
59
+ onSettingsChange,
60
+ refreshToken,
61
+ onRefreshStateChange,
62
+ }) => {
63
+ const t = useT()
64
+ const hydrated = React.useMemo(() => hydrateSettings(settings), [settings])
65
+ const [data, setData] = React.useState<CustomerRow[]>([])
66
+ const [loading, setLoading] = React.useState(true)
67
+ const [error, setError] = React.useState<string | null>(null)
68
+
69
+ const unknownLabel = t('dashboards.analytics.labels.unknown', 'Unknown')
70
+ const columns: TopNTableColumn<CustomerRow>[] = React.useMemo(
71
+ () => [
72
+ {
73
+ key: 'rank',
74
+ header: '#',
75
+ width: '40px',
76
+ },
77
+ {
78
+ key: 'customerId',
79
+ header: t('dashboards.analytics.widgets.topCustomers.column.customer', 'Customer'),
80
+ formatter: (value) => formatCustomerName(String(value || ''), unknownLabel),
81
+ },
82
+ {
83
+ key: 'revenue',
84
+ header: t('dashboards.analytics.widgets.topCustomers.column.revenue', 'Revenue'),
85
+ align: 'right',
86
+ formatter: (value: unknown) => formatCurrencySafe(value),
87
+ },
88
+ ],
89
+ [t, unknownLabel],
90
+ )
91
+
92
+ const refresh = React.useCallback(async () => {
93
+ onRefreshStateChange?.(true)
94
+ setLoading(true)
95
+ setError(null)
96
+ try {
97
+ const result = await fetchTopCustomersData(hydrated)
98
+ const tableData: CustomerRow[] = result.data.map((item, index) => ({
99
+ rank: index + 1,
100
+ customerId: item.groupLabel || String(item.groupKey || t('dashboards.analytics.labels.unknown', 'Unknown')),
101
+ revenue: item.value ?? 0,
102
+ }))
103
+ setData(tableData)
104
+ } catch (err) {
105
+ console.error('Failed to load top customers data', err)
106
+ setError(t('dashboards.analytics.widgets.topCustomers.error', 'Failed to load data'))
107
+ } finally {
108
+ setLoading(false)
109
+ onRefreshStateChange?.(false)
110
+ }
111
+ }, [hydrated, onRefreshStateChange, t])
112
+
113
+ React.useEffect(() => {
114
+ refresh().catch(() => {})
115
+ }, [refresh, refreshToken])
116
+
117
+ if (mode === 'settings') {
118
+ return (
119
+ <div className="space-y-4 text-sm">
120
+ <DateRangeSelect
121
+ id="top-customers-date-range"
122
+ label={t('dashboards.analytics.settings.dateRange', 'Date Range')}
123
+ value={hydrated.dateRange}
124
+ onChange={(dateRange: DateRangePreset) => onSettingsChange({ ...hydrated, dateRange })}
125
+ />
126
+ <div className="space-y-1.5">
127
+ <label
128
+ htmlFor="top-customers-limit"
129
+ className="text-xs font-semibold uppercase text-muted-foreground"
130
+ >
131
+ {t('dashboards.analytics.settings.limit', 'Number of items')}
132
+ </label>
133
+ <input
134
+ id="top-customers-limit"
135
+ type="number"
136
+ min={1}
137
+ max={20}
138
+ className="w-24 rounded-md border px-2 py-1 text-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
139
+ value={hydrated.limit}
140
+ onChange={(e) => {
141
+ const next = Number(e.target.value)
142
+ onSettingsChange({ ...hydrated, limit: Number.isFinite(next) ? next : hydrated.limit })
143
+ }}
144
+ />
145
+ </div>
146
+ </div>
147
+ )
148
+ }
149
+
150
+ return (
151
+ <TopNTable
152
+ data={data}
153
+ columns={columns}
154
+ loading={loading}
155
+ error={error}
156
+ emptyMessage={t('dashboards.analytics.widgets.topCustomers.empty', 'No customer data for this period')}
157
+ />
158
+ )
159
+ }
160
+
161
+ export default TopCustomersWidget
@@ -0,0 +1,24 @@
1
+ import type { DashboardWidgetModule } from '@open-mercato/shared/modules/dashboard/widgets'
2
+ import TopCustomersWidget from './widget.client'
3
+ import { DEFAULT_SETTINGS, hydrateSettings, type TopCustomersSettings } from './config'
4
+
5
+ const widget: DashboardWidgetModule<TopCustomersSettings> = {
6
+ metadata: {
7
+ id: 'dashboards.analytics.topCustomers',
8
+ title: 'Top Customers',
9
+ description: 'Top customers by revenue',
10
+ features: ['analytics.view', 'sales.orders.view', 'customers.people.view'],
11
+ defaultSize: 'md',
12
+ defaultEnabled: false,
13
+ defaultSettings: DEFAULT_SETTINGS,
14
+ tags: ['analytics', 'sales', 'customers', 'table'],
15
+ category: 'analytics',
16
+ icon: 'users',
17
+ supportsRefresh: true,
18
+ },
19
+ Widget: TopCustomersWidget,
20
+ hydrateSettings,
21
+ dehydrateSettings: (s) => ({ dateRange: s.dateRange, limit: s.limit }),
22
+ }
23
+
24
+ export default widget
@@ -0,0 +1,27 @@
1
+ import { type DateRangePreset, isValidDateRangePreset } from '@open-mercato/ui/backend/date-range'
2
+
3
+ export type ChartLayout = 'horizontal' | 'vertical'
4
+
5
+ export type TopProductsSettings = {
6
+ dateRange: DateRangePreset
7
+ limit: number
8
+ layout: ChartLayout
9
+ }
10
+
11
+ export const DEFAULT_SETTINGS: TopProductsSettings = {
12
+ dateRange: 'this_month',
13
+ limit: 10,
14
+ layout: 'horizontal',
15
+ }
16
+
17
+ export function hydrateSettings(raw: unknown): TopProductsSettings {
18
+ if (!raw || typeof raw !== 'object') return { ...DEFAULT_SETTINGS }
19
+ const obj = raw as Record<string, unknown>
20
+ const parsedLimit = Number(obj.limit)
21
+ const layout = obj.layout === 'vertical' ? 'vertical' : 'horizontal'
22
+ return {
23
+ dateRange: isValidDateRangePreset(obj.dateRange) ? obj.dateRange : DEFAULT_SETTINGS.dateRange,
24
+ limit: Number.isFinite(parsedLimit) && parsedLimit >= 1 && parsedLimit <= 20 ? Math.floor(parsedLimit) : DEFAULT_SETTINGS.limit,
25
+ layout,
26
+ }
27
+ }
@@ -0,0 +1,181 @@
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 { BarChart, type BarChartDataItem } from '@open-mercato/ui/backend/charts'
8
+ import { DateRangeSelect, InlineDateRangeSelect, type DateRangePreset } from '@open-mercato/ui/backend/date-range'
9
+ import { DEFAULT_SETTINGS, hydrateSettings, type TopProductsSettings } from './config'
10
+ import type { WidgetDataResponse } from '../../../services/widgetDataService'
11
+ import { formatCurrencyCompact } from '../../../lib/formatters'
12
+
13
+ async function fetchTopProductsData(settings: TopProductsSettings): Promise<WidgetDataResponse> {
14
+ const body = {
15
+ entityType: 'sales:order_lines',
16
+ metric: {
17
+ field: 'totalGrossAmount',
18
+ aggregate: 'sum',
19
+ },
20
+ groupBy: {
21
+ field: 'productId',
22
+ limit: settings.limit,
23
+ resolveLabels: true,
24
+ },
25
+ dateRange: {
26
+ field: 'createdAt',
27
+ preset: settings.dateRange,
28
+ },
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 top products data')
40
+ }
41
+
42
+ return call.result as WidgetDataResponse
43
+ }
44
+
45
+ function truncateLabel(
46
+ label: unknown,
47
+ t: (key: string, fallback: string) => string,
48
+ maxLength: number = 20
49
+ ): string {
50
+ if (label == null || label === '') return t('dashboards.analytics.labels.unknownProduct', 'Unknown Product')
51
+ const labelStr = String(label)
52
+ // Check for UUID-like strings or meaningless values
53
+ if (labelStr === '0' || labelStr === 'null' || labelStr === 'undefined') {
54
+ return t('dashboards.analytics.labels.unknownProduct', 'Unknown Product')
55
+ }
56
+ if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(labelStr)) {
57
+ return t('dashboards.analytics.labels.unnamedProduct', 'Unnamed Product')
58
+ }
59
+ if (labelStr.length <= maxLength) return labelStr
60
+ return labelStr.slice(0, maxLength - 3) + '...'
61
+ }
62
+
63
+ const TopProductsWidget: React.FC<DashboardWidgetComponentProps<TopProductsSettings>> = ({
64
+ mode,
65
+ settings = DEFAULT_SETTINGS,
66
+ onSettingsChange,
67
+ refreshToken,
68
+ onRefreshStateChange,
69
+ }) => {
70
+ const t = useT()
71
+ const hydrated = React.useMemo(() => hydrateSettings(settings), [settings])
72
+ const [data, setData] = React.useState<BarChartDataItem[]>([])
73
+ const [loading, setLoading] = React.useState(true)
74
+ const [error, setError] = React.useState<string | null>(null)
75
+ const fetchingRef = React.useRef(false)
76
+
77
+ const refresh = React.useCallback(async () => {
78
+ if (fetchingRef.current) return
79
+ fetchingRef.current = true
80
+ onRefreshStateChange?.(true)
81
+ setLoading(true)
82
+ setError(null)
83
+ try {
84
+ const result = await fetchTopProductsData(hydrated)
85
+ const chartData = result.data.map((item, index) => ({
86
+ name: truncateLabel(item.groupLabel ?? item.groupKey ?? `Product ${index + 1}`, t),
87
+ Revenue: item.value ?? 0,
88
+ }))
89
+ setData(chartData)
90
+ } catch (err) {
91
+ console.error('Failed to load top products data', err)
92
+ setError(t('dashboards.analytics.widgets.topProducts.error', 'Failed to load data'))
93
+ } finally {
94
+ setLoading(false)
95
+ onRefreshStateChange?.(false)
96
+ fetchingRef.current = false
97
+ }
98
+ }, [hydrated, onRefreshStateChange, t])
99
+
100
+ React.useEffect(() => {
101
+ refresh().catch(() => {})
102
+ }, [refresh, refreshToken])
103
+
104
+ if (mode === 'settings') {
105
+ return (
106
+ <div className="space-y-4 text-sm">
107
+ <DateRangeSelect
108
+ id="top-products-date-range"
109
+ label={t('dashboards.analytics.settings.dateRange', 'Date Range')}
110
+ value={hydrated.dateRange}
111
+ onChange={(dateRange: DateRangePreset) => onSettingsChange({ ...hydrated, dateRange })}
112
+ />
113
+ <div className="space-y-1.5">
114
+ <label
115
+ htmlFor="top-products-limit"
116
+ className="text-xs font-semibold uppercase text-muted-foreground"
117
+ >
118
+ {t('dashboards.analytics.settings.limit', 'Number of items')}
119
+ </label>
120
+ <input
121
+ id="top-products-limit"
122
+ type="number"
123
+ min={1}
124
+ max={20}
125
+ className="w-24 rounded-md border px-2 py-1 text-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
126
+ value={hydrated.limit}
127
+ onChange={(e) => {
128
+ const next = Number(e.target.value)
129
+ onSettingsChange({ ...hydrated, limit: Number.isFinite(next) ? next : hydrated.limit })
130
+ }}
131
+ />
132
+ </div>
133
+ <div className="space-y-1.5">
134
+ <label
135
+ htmlFor="top-products-layout"
136
+ className="text-xs font-semibold uppercase text-muted-foreground"
137
+ >
138
+ {t('dashboards.analytics.settings.chartLayout', 'Chart Layout')}
139
+ </label>
140
+ <select
141
+ id="top-products-layout"
142
+ className="w-full rounded-md border bg-background px-2 py-1 text-sm text-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
143
+ value={hydrated.layout}
144
+ onChange={(e) => onSettingsChange({ ...hydrated, layout: e.target.value as 'horizontal' | 'vertical' })}
145
+ >
146
+ <option value="horizontal">{t('dashboards.analytics.settings.horizontal', 'Horizontal')}</option>
147
+ <option value="vertical">{t('dashboards.analytics.settings.vertical', 'Vertical')}</option>
148
+ </select>
149
+ </div>
150
+ </div>
151
+ )
152
+ }
153
+
154
+ return (
155
+ <div className="flex flex-col h-full">
156
+ <div className="flex justify-end mb-2">
157
+ <InlineDateRangeSelect
158
+ value={hydrated.dateRange}
159
+ onChange={(dateRange) => onSettingsChange({ ...hydrated, dateRange })}
160
+ />
161
+ </div>
162
+ <div className="flex-1 min-h-0">
163
+ <BarChart
164
+ data={data}
165
+ index="name"
166
+ categories={['Revenue']}
167
+ categoryLabels={{ Revenue: t('dashboards.analytics.widgets.topCustomers.column.revenue', 'Revenue') }}
168
+ loading={loading}
169
+ error={error}
170
+ layout={hydrated.layout}
171
+ valueFormatter={formatCurrencyCompact}
172
+ colors={['emerald']}
173
+ showLegend={false}
174
+ emptyMessage={t('dashboards.analytics.widgets.topProducts.empty', 'No product sales data for this period')}
175
+ />
176
+ </div>
177
+ </div>
178
+ )
179
+ }
180
+
181
+ export default TopProductsWidget
@@ -0,0 +1,24 @@
1
+ import type { DashboardWidgetModule } from '@open-mercato/shared/modules/dashboard/widgets'
2
+ import TopProductsWidget from './widget.client'
3
+ import { DEFAULT_SETTINGS, hydrateSettings, type TopProductsSettings } from './config'
4
+
5
+ const widget: DashboardWidgetModule<TopProductsSettings> = {
6
+ metadata: {
7
+ id: 'dashboards.analytics.topProducts',
8
+ title: 'Top Products',
9
+ description: 'Top-selling products by revenue',
10
+ features: ['analytics.view', 'sales.orders.view'],
11
+ defaultSize: 'md',
12
+ defaultEnabled: false,
13
+ defaultSettings: DEFAULT_SETTINGS,
14
+ tags: ['analytics', 'sales', 'products', 'chart'],
15
+ category: 'analytics',
16
+ icon: 'bar-chart-2',
17
+ supportsRefresh: true,
18
+ },
19
+ Widget: TopProductsWidget,
20
+ hydrateSettings,
21
+ dehydrateSettings: (s) => ({ dateRange: s.dateRange, limit: s.limit, layout: s.layout }),
22
+ }
23
+
24
+ export default widget
@@ -0,0 +1,64 @@
1
+ import type { AnalyticsModuleConfig } from '@open-mercato/shared/modules/analytics'
2
+
3
+ export const analyticsConfig: AnalyticsModuleConfig = {
4
+ entities: [
5
+ {
6
+ entityId: 'sales:orders',
7
+ requiredFeatures: ['sales.orders.view'],
8
+ entityConfig: {
9
+ tableName: 'sales_orders',
10
+ dateField: 'placed_at',
11
+ defaultScopeFields: ['tenant_id', 'organization_id'],
12
+ },
13
+ fieldMappings: {
14
+ id: { dbColumn: 'id', type: 'uuid' },
15
+ grandTotalGrossAmount: { dbColumn: 'grand_total_gross_amount', type: 'numeric' },
16
+ grandTotalNetAmount: { dbColumn: 'grand_total_net_amount', type: 'numeric' },
17
+ subtotalGrossAmount: { dbColumn: 'subtotal_gross_amount', type: 'numeric' },
18
+ subtotalNetAmount: { dbColumn: 'subtotal_net_amount', type: 'numeric' },
19
+ discountTotalAmount: { dbColumn: 'discount_total_amount', type: 'numeric' },
20
+ taxTotalAmount: { dbColumn: 'tax_total_amount', type: 'numeric' },
21
+ lineItemCount: { dbColumn: 'line_item_count', type: 'numeric' },
22
+ status: { dbColumn: 'status', type: 'text' },
23
+ fulfillmentStatus: { dbColumn: 'fulfillment_status', type: 'text' },
24
+ paymentStatus: { dbColumn: 'payment_status', type: 'text' },
25
+ customerEntityId: { dbColumn: 'customer_entity_id', type: 'uuid' },
26
+ channelId: { dbColumn: 'channel_id', type: 'uuid' },
27
+ placedAt: { dbColumn: 'placed_at', type: 'timestamp' },
28
+ currencyCode: { dbColumn: 'currency_code', type: 'text' },
29
+ shippingAddressSnapshot: { dbColumn: 'shipping_address_snapshot', type: 'jsonb' },
30
+ },
31
+ labelResolvers: {
32
+ customerEntityId: { table: 'customer_entities', idColumn: 'id', labelColumn: 'display_name' },
33
+ channelId: { table: 'sales_channels', idColumn: 'id', labelColumn: 'name' },
34
+ },
35
+ },
36
+ {
37
+ entityId: 'sales:order_lines',
38
+ requiredFeatures: ['sales.orders.view'],
39
+ entityConfig: {
40
+ tableName: 'sales_order_lines',
41
+ dateField: 'created_at',
42
+ defaultScopeFields: ['tenant_id', 'organization_id'],
43
+ },
44
+ fieldMappings: {
45
+ id: { dbColumn: 'id', type: 'uuid' },
46
+ totalGrossAmount: { dbColumn: 'total_gross_amount', type: 'numeric' },
47
+ totalNetAmount: { dbColumn: 'total_net_amount', type: 'numeric' },
48
+ unitGrossPrice: { dbColumn: 'unit_gross_price', type: 'numeric' },
49
+ quantity: { dbColumn: 'quantity', type: 'numeric' },
50
+ productId: { dbColumn: 'product_id', type: 'uuid' },
51
+ productVariantId: { dbColumn: 'product_variant_id', type: 'uuid' },
52
+ status: { dbColumn: 'status', type: 'text' },
53
+ createdAt: { dbColumn: 'created_at', type: 'timestamp' },
54
+ },
55
+ labelResolvers: {
56
+ productId: { table: 'catalog_products', idColumn: 'id', labelColumn: 'title' },
57
+ productVariantId: { table: 'catalog_product_variants', idColumn: 'id', labelColumn: 'name' },
58
+ },
59
+ },
60
+ ],
61
+ }
62
+
63
+ export default analyticsConfig
64
+ export const config = analyticsConfig