@open-mercato/core 0.4.2-canary-cf7d9b4116 → 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,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 RevenueKpiSettings } from './config'
15
+ import type { WidgetDataResponse } from '../../../services/widgetDataService'
16
+ import { formatCurrency } from '../../../lib/formatters'
17
+
18
+ async function fetchRevenueData(settings: RevenueKpiSettings): Promise<WidgetDataResponse> {
19
+ const body = {
20
+ entityType: 'sales:orders',
21
+ metric: {
22
+ field: 'grandTotalGrossAmount',
23
+ aggregate: 'sum',
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 revenue data')
41
+ }
42
+
43
+ return call.result as WidgetDataResponse
44
+ }
45
+
46
+ const RevenueKpiWidget: React.FC<DashboardWidgetComponentProps<RevenueKpiSettings>> = ({
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 fetchRevenueData(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 revenue KPI data', err)
77
+ setError(t('dashboards.analytics.widgets.revenueKpi.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="revenue-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={formatCurrency}
125
+ headerAction={
126
+ <InlineDateRangeSelect
127
+ value={hydrated.dateRange}
128
+ onChange={(dateRange) => onSettingsChange({ ...hydrated, dateRange })}
129
+ />
130
+ }
131
+ />
132
+ )
133
+ }
134
+
135
+ export default RevenueKpiWidget
@@ -0,0 +1,24 @@
1
+ import type { DashboardWidgetModule } from '@open-mercato/shared/modules/dashboard/widgets'
2
+ import RevenueKpiWidget from './widget.client'
3
+ import { DEFAULT_SETTINGS, hydrateSettings, type RevenueKpiSettings } from './config'
4
+
5
+ const widget: DashboardWidgetModule<RevenueKpiSettings> = {
6
+ metadata: {
7
+ id: 'dashboards.analytics.revenueKpi',
8
+ title: 'Revenue',
9
+ description: 'Total revenue 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: 'dollar-sign',
17
+ supportsRefresh: true,
18
+ },
19
+ Widget: RevenueKpiWidget,
20
+ hydrateSettings,
21
+ dehydrateSettings: (s) => ({ dateRange: s.dateRange, showComparison: s.showComparison }),
22
+ }
23
+
24
+ export default widget
@@ -0,0 +1,24 @@
1
+ import { type DateRangePreset, isValidDateRangePreset } from '@open-mercato/ui/backend/date-range'
2
+ import { type DateGranularity, isValidGranularity } from '../../../lib/aggregations'
3
+
4
+ export type RevenueTrendSettings = {
5
+ dateRange: DateRangePreset
6
+ granularity: DateGranularity
7
+ showArea: boolean
8
+ }
9
+
10
+ export const DEFAULT_SETTINGS: RevenueTrendSettings = {
11
+ dateRange: 'last_30_days',
12
+ granularity: 'day',
13
+ showArea: true,
14
+ }
15
+
16
+ export function hydrateSettings(raw: unknown): RevenueTrendSettings {
17
+ if (!raw || typeof raw !== 'object') return { ...DEFAULT_SETTINGS }
18
+ const obj = raw as Record<string, unknown>
19
+ return {
20
+ dateRange: isValidDateRangePreset(obj.dateRange) ? obj.dateRange : DEFAULT_SETTINGS.dateRange,
21
+ granularity: isValidGranularity(obj.granularity) ? obj.granularity : DEFAULT_SETTINGS.granularity,
22
+ showArea: typeof obj.showArea === 'boolean' ? obj.showArea : DEFAULT_SETTINGS.showArea,
23
+ }
24
+ }
@@ -0,0 +1,220 @@
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, useLocale } from '@open-mercato/shared/lib/i18n/context'
7
+ import { LineChart, type LineChartDataItem } from '@open-mercato/ui/backend/charts'
8
+ import {
9
+ DateRangeSelect,
10
+ InlineDateRangeSelect,
11
+ type DateRangePreset,
12
+ } from '@open-mercato/ui/backend/date-range'
13
+ import type { DateGranularity } from '@open-mercato/shared/modules/analytics'
14
+ import { DEFAULT_SETTINGS, hydrateSettings, type RevenueTrendSettings } from './config'
15
+ import type { WidgetDataResponse } from '../../../services/widgetDataService'
16
+ import { formatCurrencyCompact } from '../../../lib/formatters'
17
+
18
+ async function fetchRevenueTrendData(settings: RevenueTrendSettings): Promise<WidgetDataResponse> {
19
+ const body = {
20
+ entityType: 'sales:orders',
21
+ metric: {
22
+ field: 'grandTotalGrossAmount',
23
+ aggregate: 'sum',
24
+ },
25
+ groupBy: {
26
+ field: 'placedAt',
27
+ granularity: settings.granularity,
28
+ },
29
+ dateRange: {
30
+ field: 'placedAt',
31
+ preset: settings.dateRange,
32
+ },
33
+ }
34
+
35
+ const call = await apiCall<WidgetDataResponse>('/api/dashboards/widgets/data', {
36
+ method: 'POST',
37
+ headers: { 'Content-Type': 'application/json' },
38
+ body: JSON.stringify(body),
39
+ })
40
+
41
+ if (!call.ok) {
42
+ const errorMsg = (call.result as Record<string, unknown>)?.error
43
+ throw new Error(typeof errorMsg === 'string' ? errorMsg : 'Failed to fetch revenue trend data')
44
+ }
45
+
46
+ return call.result as WidgetDataResponse
47
+ }
48
+
49
+ function formatDate(dateStr: string | null, granularity: DateGranularity, locale?: string): string {
50
+ if (!dateStr) return '--'
51
+ try {
52
+ const date = new Date(dateStr)
53
+ const localeStr = locale ?? undefined
54
+ switch (granularity) {
55
+ case 'day':
56
+ case 'week':
57
+ return date.toLocaleDateString(localeStr, { month: 'short', day: 'numeric' })
58
+ case 'month':
59
+ return date.toLocaleDateString(localeStr, { month: 'short', year: 'numeric' })
60
+ case 'quarter': {
61
+ const quarter = Math.floor(date.getMonth() / 3) + 1
62
+ return `Q${quarter} ${date.getFullYear()}`
63
+ }
64
+ case 'year':
65
+ return date.toLocaleDateString(localeStr, { year: 'numeric' })
66
+ default:
67
+ return date.toLocaleDateString(localeStr, { month: 'short', day: 'numeric' })
68
+ }
69
+ } catch {
70
+ return String(dateStr)
71
+ }
72
+ }
73
+
74
+ const GRANULARITY_OPTIONS: { value: DateGranularity; labelKey: string }[] = [
75
+ { value: 'day', labelKey: 'dashboards.analytics.granularity.day' },
76
+ { value: 'week', labelKey: 'dashboards.analytics.granularity.week' },
77
+ { value: 'month', labelKey: 'dashboards.analytics.granularity.month' },
78
+ { value: 'quarter', labelKey: 'dashboards.analytics.granularity.quarter' },
79
+ { value: 'year', labelKey: 'dashboards.analytics.granularity.year' },
80
+ ]
81
+
82
+ function getAutoGranularity(dateRange: DateRangePreset): DateGranularity {
83
+ switch (dateRange) {
84
+ case 'today':
85
+ case 'yesterday':
86
+ case 'last_7_days':
87
+ return 'day'
88
+ case 'this_week':
89
+ case 'last_week':
90
+ case 'last_30_days':
91
+ return 'day'
92
+ case 'this_month':
93
+ case 'last_month':
94
+ case 'last_90_days':
95
+ return 'week'
96
+ case 'this_quarter':
97
+ case 'last_quarter':
98
+ return 'week'
99
+ case 'this_year':
100
+ case 'last_year':
101
+ return 'month'
102
+ default:
103
+ return 'day'
104
+ }
105
+ }
106
+
107
+ const RevenueTrendWidget: React.FC<DashboardWidgetComponentProps<RevenueTrendSettings>> = ({
108
+ mode,
109
+ settings = DEFAULT_SETTINGS,
110
+ onSettingsChange,
111
+ refreshToken,
112
+ onRefreshStateChange,
113
+ }) => {
114
+ const t = useT()
115
+ const locale = useLocale()
116
+ const hydrated = React.useMemo(() => hydrateSettings(settings), [settings])
117
+ const [data, setData] = React.useState<LineChartDataItem[]>([])
118
+ const [loading, setLoading] = React.useState(true)
119
+ const [error, setError] = React.useState<string | null>(null)
120
+
121
+ const refresh = React.useCallback(async () => {
122
+ onRefreshStateChange?.(true)
123
+ setLoading(true)
124
+ setError(null)
125
+ try {
126
+ const result = await fetchRevenueTrendData(hydrated)
127
+ const sortedData = [...result.data].sort((a, b) => {
128
+ const aTime = new Date(a.groupKey as string || 0).getTime()
129
+ const bTime = new Date(b.groupKey as string || 0).getTime()
130
+ return aTime - bTime
131
+ })
132
+ const chartData = sortedData.map((item) => ({
133
+ date: formatDate(item.groupKey as string | null, hydrated.granularity, locale),
134
+ Revenue: item.value ?? 0,
135
+ }))
136
+ setData(chartData)
137
+ } catch (err) {
138
+ console.error('Failed to load revenue trend data', err)
139
+ setError(t('dashboards.analytics.widgets.revenueTrend.error', 'Failed to load data'))
140
+ } finally {
141
+ setLoading(false)
142
+ onRefreshStateChange?.(false)
143
+ }
144
+ }, [hydrated, locale, onRefreshStateChange, t])
145
+
146
+ React.useEffect(() => {
147
+ refresh().catch(() => {})
148
+ }, [refresh, refreshToken])
149
+
150
+ if (mode === 'settings') {
151
+ return (
152
+ <div className="space-y-4 text-sm">
153
+ <DateRangeSelect
154
+ id="revenue-trend-date-range"
155
+ label={t('dashboards.analytics.settings.dateRange', 'Date Range')}
156
+ value={hydrated.dateRange}
157
+ onChange={(dateRange: DateRangePreset) => onSettingsChange({ ...hydrated, dateRange })}
158
+ />
159
+ <div className="space-y-1.5">
160
+ <label
161
+ htmlFor="revenue-trend-granularity"
162
+ className="text-xs font-semibold uppercase text-muted-foreground"
163
+ >
164
+ {t('dashboards.analytics.settings.granularity', 'Granularity')}
165
+ </label>
166
+ <select
167
+ id="revenue-trend-granularity"
168
+ 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"
169
+ value={hydrated.granularity}
170
+ onChange={(e) => onSettingsChange({ ...hydrated, granularity: e.target.value as DateGranularity })}
171
+ >
172
+ {GRANULARITY_OPTIONS.map((opt) => (
173
+ <option key={opt.value} value={opt.value}>
174
+ {t(opt.labelKey, opt.value)}
175
+ </option>
176
+ ))}
177
+ </select>
178
+ </div>
179
+ <div className="space-y-1.5">
180
+ <label className="flex items-center gap-2 text-sm">
181
+ <input
182
+ type="checkbox"
183
+ checked={hydrated.showArea}
184
+ onChange={(e) => onSettingsChange({ ...hydrated, showArea: e.target.checked })}
185
+ className="h-4 w-4 rounded border focus:ring-primary"
186
+ />
187
+ {t('dashboards.analytics.settings.showArea', 'Show area fill')}
188
+ </label>
189
+ </div>
190
+ </div>
191
+ )
192
+ }
193
+
194
+ const effectiveGranularity = hydrated.granularity === 'day' ? getAutoGranularity(hydrated.dateRange) : hydrated.granularity
195
+
196
+ return (
197
+ <div>
198
+ <div className="mb-2 flex justify-end">
199
+ <InlineDateRangeSelect
200
+ value={hydrated.dateRange}
201
+ onChange={(dateRange) => onSettingsChange({ ...hydrated, dateRange, granularity: getAutoGranularity(dateRange) })}
202
+ />
203
+ </div>
204
+ <LineChart
205
+ data={data}
206
+ index="date"
207
+ categories={['Revenue']}
208
+ categoryLabels={{ Revenue: t('dashboards.analytics.widgets.topCustomers.column.revenue', 'Revenue') }}
209
+ loading={loading}
210
+ error={error}
211
+ showArea={hydrated.showArea}
212
+ valueFormatter={formatCurrencyCompact}
213
+ colors={['blue']}
214
+ emptyMessage={t('dashboards.analytics.widgets.revenueTrend.empty', 'No revenue data for this period')}
215
+ />
216
+ </div>
217
+ )
218
+ }
219
+
220
+ export default RevenueTrendWidget
@@ -0,0 +1,24 @@
1
+ import type { DashboardWidgetModule } from '@open-mercato/shared/modules/dashboard/widgets'
2
+ import RevenueTrendWidget from './widget.client'
3
+ import { DEFAULT_SETTINGS, hydrateSettings, type RevenueTrendSettings } from './config'
4
+
5
+ const widget: DashboardWidgetModule<RevenueTrendSettings> = {
6
+ metadata: {
7
+ id: 'dashboards.analytics.revenueTrend',
8
+ title: 'Revenue Trend',
9
+ description: 'Revenue over time with customizable granularity',
10
+ features: ['analytics.view', 'sales.orders.view'],
11
+ defaultSize: 'lg',
12
+ defaultEnabled: false,
13
+ defaultSettings: DEFAULT_SETTINGS,
14
+ tags: ['analytics', 'sales', 'chart'],
15
+ category: 'analytics',
16
+ icon: 'line-chart',
17
+ supportsRefresh: true,
18
+ },
19
+ Widget: RevenueTrendWidget,
20
+ hydrateSettings,
21
+ dehydrateSettings: (s) => ({ dateRange: s.dateRange, granularity: s.granularity, showArea: s.showArea }),
22
+ }
23
+
24
+ export default widget
@@ -0,0 +1,21 @@
1
+ import { type DateRangePreset, isValidDateRangePreset } from '@open-mercato/ui/backend/date-range'
2
+
3
+ export type SalesByRegionSettings = {
4
+ dateRange: DateRangePreset
5
+ limit: number
6
+ }
7
+
8
+ export const DEFAULT_SETTINGS: SalesByRegionSettings = {
9
+ dateRange: 'this_month',
10
+ limit: 10,
11
+ }
12
+
13
+ export function hydrateSettings(raw: unknown): SalesByRegionSettings {
14
+ if (!raw || typeof raw !== 'object') return { ...DEFAULT_SETTINGS }
15
+ const obj = raw as Record<string, unknown>
16
+ const parsedLimit = Number(obj.limit)
17
+ return {
18
+ dateRange: isValidDateRangePreset(obj.dateRange) ? obj.dateRange : DEFAULT_SETTINGS.dateRange,
19
+ limit: Number.isFinite(parsedLimit) && parsedLimit >= 1 && parsedLimit <= 20 ? Math.floor(parsedLimit) : DEFAULT_SETTINGS.limit,
20
+ }
21
+ }
@@ -0,0 +1,131 @@
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, type DateRangePreset } from '@open-mercato/ui/backend/date-range'
9
+ import { DEFAULT_SETTINGS, hydrateSettings, type SalesByRegionSettings } from './config'
10
+ import type { WidgetDataResponse } from '../../../services/widgetDataService'
11
+ import { formatCurrencyCompact } from '../../../lib/formatters'
12
+
13
+ async function fetchSalesByRegionData(settings: SalesByRegionSettings): Promise<WidgetDataResponse> {
14
+ const body = {
15
+ entityType: 'sales:orders',
16
+ metric: {
17
+ field: 'grandTotalGrossAmount',
18
+ aggregate: 'sum',
19
+ },
20
+ groupBy: {
21
+ field: 'shippingAddressSnapshot.region',
22
+ limit: settings.limit,
23
+ },
24
+ dateRange: {
25
+ field: 'placedAt',
26
+ preset: settings.dateRange,
27
+ },
28
+ }
29
+
30
+ const call = await apiCall<WidgetDataResponse>('/api/dashboards/widgets/data', {
31
+ method: 'POST',
32
+ headers: { 'Content-Type': 'application/json' },
33
+ body: JSON.stringify(body),
34
+ })
35
+
36
+ if (!call.ok) {
37
+ const errorMsg = (call.result as Record<string, unknown>)?.error
38
+ throw new Error(typeof errorMsg === 'string' ? errorMsg : 'Failed to fetch sales by region data')
39
+ }
40
+
41
+ return call.result as WidgetDataResponse
42
+ }
43
+
44
+ const SalesByRegionWidget: React.FC<DashboardWidgetComponentProps<SalesByRegionSettings>> = ({
45
+ mode,
46
+ settings = DEFAULT_SETTINGS,
47
+ onSettingsChange,
48
+ refreshToken,
49
+ onRefreshStateChange,
50
+ }) => {
51
+ const t = useT()
52
+ const hydrated = React.useMemo(() => hydrateSettings(settings), [settings])
53
+ const [data, setData] = React.useState<BarChartDataItem[]>([])
54
+ const [loading, setLoading] = React.useState(true)
55
+ const [error, setError] = React.useState<string | null>(null)
56
+
57
+ const refresh = React.useCallback(async () => {
58
+ onRefreshStateChange?.(true)
59
+ setLoading(true)
60
+ setError(null)
61
+ try {
62
+ const result = await fetchSalesByRegionData(hydrated)
63
+ const chartData = result.data.map((item) => ({
64
+ region: String(item.groupKey || t('dashboards.analytics.labels.unknown', 'Unknown')),
65
+ Revenue: item.value ?? 0,
66
+ }))
67
+ setData(chartData)
68
+ } catch (err) {
69
+ console.error('Failed to load sales by region data', err)
70
+ setError(t('dashboards.analytics.widgets.salesByRegion.error', 'Failed to load data'))
71
+ } finally {
72
+ setLoading(false)
73
+ onRefreshStateChange?.(false)
74
+ }
75
+ }, [hydrated, onRefreshStateChange, t])
76
+
77
+ React.useEffect(() => {
78
+ refresh().catch(() => {})
79
+ }, [refresh, refreshToken])
80
+
81
+ if (mode === 'settings') {
82
+ return (
83
+ <div className="space-y-4 text-sm">
84
+ <DateRangeSelect
85
+ id="sales-by-region-date-range"
86
+ label={t('dashboards.analytics.settings.dateRange', 'Date Range')}
87
+ value={hydrated.dateRange}
88
+ onChange={(dateRange: DateRangePreset) => onSettingsChange({ ...hydrated, dateRange })}
89
+ />
90
+ <div className="space-y-1.5">
91
+ <label
92
+ htmlFor="sales-by-region-limit"
93
+ className="text-xs font-semibold uppercase text-muted-foreground"
94
+ >
95
+ {t('dashboards.analytics.settings.limit', 'Number of items')}
96
+ </label>
97
+ <input
98
+ id="sales-by-region-limit"
99
+ type="number"
100
+ min={1}
101
+ max={20}
102
+ className="w-24 rounded-md border px-2 py-1 text-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
103
+ value={hydrated.limit}
104
+ onChange={(e) => {
105
+ const next = Number(e.target.value)
106
+ onSettingsChange({ ...hydrated, limit: Number.isFinite(next) ? next : hydrated.limit })
107
+ }}
108
+ />
109
+ </div>
110
+ </div>
111
+ )
112
+ }
113
+
114
+ return (
115
+ <BarChart
116
+ data={data}
117
+ index="region"
118
+ categories={['Revenue']}
119
+ categoryLabels={{ Revenue: t('dashboards.analytics.widgets.topCustomers.column.revenue', 'Revenue') }}
120
+ loading={loading}
121
+ error={error}
122
+ layout="horizontal"
123
+ valueFormatter={formatCurrencyCompact}
124
+ colors={['cyan']}
125
+ showLegend={false}
126
+ emptyMessage={t('dashboards.analytics.widgets.salesByRegion.empty', 'No regional sales data for this period')}
127
+ />
128
+ )
129
+ }
130
+
131
+ export default SalesByRegionWidget
@@ -0,0 +1,24 @@
1
+ import type { DashboardWidgetModule } from '@open-mercato/shared/modules/dashboard/widgets'
2
+ import SalesByRegionWidget from './widget.client'
3
+ import { DEFAULT_SETTINGS, hydrateSettings, type SalesByRegionSettings } from './config'
4
+
5
+ const widget: DashboardWidgetModule<SalesByRegionSettings> = {
6
+ metadata: {
7
+ id: 'dashboards.analytics.salesByRegion',
8
+ title: 'Sales by Region',
9
+ description: 'Revenue distribution by shipping region',
10
+ features: ['analytics.view', 'sales.orders.view'],
11
+ defaultSize: 'md',
12
+ defaultEnabled: false,
13
+ defaultSettings: DEFAULT_SETTINGS,
14
+ tags: ['analytics', 'sales', 'geography', 'chart'],
15
+ category: 'analytics',
16
+ icon: 'map-pin',
17
+ supportsRefresh: true,
18
+ },
19
+ Widget: SalesByRegionWidget,
20
+ hydrateSettings,
21
+ dehydrateSettings: (s) => ({ dateRange: s.dateRange, limit: s.limit }),
22
+ }
23
+
24
+ export default widget
@@ -0,0 +1,21 @@
1
+ import { type DateRangePreset, isValidDateRangePreset } from '@open-mercato/ui/backend/date-range'
2
+
3
+ export type TopCustomersSettings = {
4
+ dateRange: DateRangePreset
5
+ limit: number
6
+ }
7
+
8
+ export const DEFAULT_SETTINGS: TopCustomersSettings = {
9
+ dateRange: 'this_month',
10
+ limit: 10,
11
+ }
12
+
13
+ export function hydrateSettings(raw: unknown): TopCustomersSettings {
14
+ if (!raw || typeof raw !== 'object') return { ...DEFAULT_SETTINGS }
15
+ const obj = raw as Record<string, unknown>
16
+ const parsedLimit = Number(obj.limit)
17
+ return {
18
+ dateRange: isValidDateRangePreset(obj.dateRange) ? obj.dateRange : DEFAULT_SETTINGS.dateRange,
19
+ limit: Number.isFinite(parsedLimit) && parsedLimit >= 1 && parsedLimit <= 20 ? Math.floor(parsedLimit) : DEFAULT_SETTINGS.limit,
20
+ }
21
+ }