@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.
- 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,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
|
+
}
|