@open-mercato/core 0.6.4-develop.4121.1.0d7f20d229 → 0.6.4-develop.4133.1.48fc6c8f7b
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/.turbo/turbo-build.log +1 -1
- package/dist/modules/dashboards/api/widgets/data/batch/route.js +137 -0
- package/dist/modules/dashboards/api/widgets/data/batch/route.js.map +7 -0
- package/dist/modules/dashboards/api/widgets/data/route.js +1 -75
- package/dist/modules/dashboards/api/widgets/data/route.js.map +2 -2
- package/dist/modules/dashboards/api/widgets/data/schema.js +85 -0
- package/dist/modules/dashboards/api/widgets/data/schema.js.map +7 -0
- package/dist/modules/dashboards/lib/widgetDataBatch.js +49 -0
- package/dist/modules/dashboards/lib/widgetDataBatch.js.map +7 -0
- package/dist/modules/dashboards/widgets/dashboard/aov-kpi/widget.client.js +6 -14
- package/dist/modules/dashboards/widgets/dashboard/aov-kpi/widget.client.js.map +2 -2
- package/dist/modules/dashboards/widgets/dashboard/new-customers-kpi/widget.client.js +6 -14
- package/dist/modules/dashboards/widgets/dashboard/new-customers-kpi/widget.client.js.map +2 -2
- package/dist/modules/dashboards/widgets/dashboard/orders-by-status/widget.client.js +6 -14
- package/dist/modules/dashboards/widgets/dashboard/orders-by-status/widget.client.js.map +2 -2
- package/dist/modules/dashboards/widgets/dashboard/orders-kpi/widget.client.js +6 -14
- package/dist/modules/dashboards/widgets/dashboard/orders-kpi/widget.client.js.map +2 -2
- package/dist/modules/dashboards/widgets/dashboard/pipeline-summary/widget.client.js +6 -14
- package/dist/modules/dashboards/widgets/dashboard/pipeline-summary/widget.client.js.map +2 -2
- package/dist/modules/dashboards/widgets/dashboard/revenue-kpi/widget.client.js +6 -14
- package/dist/modules/dashboards/widgets/dashboard/revenue-kpi/widget.client.js.map +2 -2
- package/dist/modules/dashboards/widgets/dashboard/revenue-trend/widget.client.js +6 -14
- package/dist/modules/dashboards/widgets/dashboard/revenue-trend/widget.client.js.map +2 -2
- package/dist/modules/dashboards/widgets/dashboard/sales-by-region/widget.client.js +6 -14
- package/dist/modules/dashboards/widgets/dashboard/sales-by-region/widget.client.js.map +2 -2
- package/dist/modules/dashboards/widgets/dashboard/top-customers/widget.client.js +6 -14
- package/dist/modules/dashboards/widgets/dashboard/top-customers/widget.client.js.map +2 -2
- package/dist/modules/dashboards/widgets/dashboard/top-products/widget.client.js +6 -14
- package/dist/modules/dashboards/widgets/dashboard/top-products/widget.client.js.map +2 -2
- package/package.json +7 -7
- package/src/modules/dashboards/api/widgets/data/batch/route.ts +168 -0
- package/src/modules/dashboards/api/widgets/data/route.ts +1 -90
- package/src/modules/dashboards/api/widgets/data/schema.ts +90 -0
- package/src/modules/dashboards/lib/widgetDataBatch.ts +89 -0
- package/src/modules/dashboards/widgets/dashboard/aov-kpi/widget.client.tsx +6 -16
- package/src/modules/dashboards/widgets/dashboard/new-customers-kpi/widget.client.tsx +6 -16
- package/src/modules/dashboards/widgets/dashboard/orders-by-status/widget.client.tsx +6 -16
- package/src/modules/dashboards/widgets/dashboard/orders-kpi/widget.client.tsx +6 -16
- package/src/modules/dashboards/widgets/dashboard/pipeline-summary/widget.client.tsx +6 -16
- package/src/modules/dashboards/widgets/dashboard/revenue-kpi/widget.client.tsx +6 -16
- package/src/modules/dashboards/widgets/dashboard/revenue-trend/widget.client.tsx +6 -16
- package/src/modules/dashboards/widgets/dashboard/sales-by-region/widget.client.tsx +6 -16
- package/src/modules/dashboards/widgets/dashboard/top-customers/widget.client.tsx +6 -16
- package/src/modules/dashboards/widgets/dashboard/top-products/widget.client.tsx +6 -16
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
3
3
|
import * as React from "react";
|
|
4
|
-
import {
|
|
4
|
+
import { useWidgetData } from "@open-mercato/ui/backend/dashboard/widgetData";
|
|
5
5
|
import { useT } from "@open-mercato/shared/lib/i18n/context";
|
|
6
6
|
import { BarChart } from "@open-mercato/ui/backend/charts";
|
|
7
7
|
import { DateRangeSelect } from "@open-mercato/ui/backend/date-range";
|
|
8
8
|
import { Input } from "@open-mercato/ui/primitives/input";
|
|
9
9
|
import { DEFAULT_SETTINGS, hydrateSettings } from "./config.js";
|
|
10
10
|
import { formatCurrencyCompact } from "../../../lib/formatters.js";
|
|
11
|
-
async function fetchSalesByRegionData(settings) {
|
|
11
|
+
async function fetchSalesByRegionData(settings, fetchWidgetData) {
|
|
12
12
|
const body = {
|
|
13
13
|
entityType: "sales:orders",
|
|
14
14
|
metric: {
|
|
@@ -24,16 +24,7 @@ async function fetchSalesByRegionData(settings) {
|
|
|
24
24
|
preset: settings.dateRange
|
|
25
25
|
}
|
|
26
26
|
};
|
|
27
|
-
|
|
28
|
-
method: "POST",
|
|
29
|
-
headers: { "Content-Type": "application/json" },
|
|
30
|
-
body: JSON.stringify(body)
|
|
31
|
-
});
|
|
32
|
-
if (!call.ok) {
|
|
33
|
-
const errorMsg = call.result?.error;
|
|
34
|
-
throw new Error(typeof errorMsg === "string" ? errorMsg : "Failed to fetch sales by region data");
|
|
35
|
-
}
|
|
36
|
-
return call.result;
|
|
27
|
+
return fetchWidgetData(body);
|
|
37
28
|
}
|
|
38
29
|
const SalesByRegionWidget = ({
|
|
39
30
|
mode,
|
|
@@ -47,12 +38,13 @@ const SalesByRegionWidget = ({
|
|
|
47
38
|
const [data, setData] = React.useState([]);
|
|
48
39
|
const [loading, setLoading] = React.useState(true);
|
|
49
40
|
const [error, setError] = React.useState(null);
|
|
41
|
+
const fetchWidgetData = useWidgetData();
|
|
50
42
|
const refresh = React.useCallback(async () => {
|
|
51
43
|
onRefreshStateChange?.(true);
|
|
52
44
|
setLoading(true);
|
|
53
45
|
setError(null);
|
|
54
46
|
try {
|
|
55
|
-
const result = await fetchSalesByRegionData(hydrated);
|
|
47
|
+
const result = await fetchSalesByRegionData(hydrated, fetchWidgetData);
|
|
56
48
|
const chartData = result.data.map((item) => ({
|
|
57
49
|
region: String(item.groupKey || t("dashboards.analytics.labels.unknown", "Unknown")),
|
|
58
50
|
Revenue: item.value ?? 0
|
|
@@ -65,7 +57,7 @@ const SalesByRegionWidget = ({
|
|
|
65
57
|
setLoading(false);
|
|
66
58
|
onRefreshStateChange?.(false);
|
|
67
59
|
}
|
|
68
|
-
}, [hydrated, onRefreshStateChange, t]);
|
|
60
|
+
}, [hydrated, fetchWidgetData, onRefreshStateChange, t]);
|
|
69
61
|
React.useEffect(() => {
|
|
70
62
|
refresh().catch(() => {
|
|
71
63
|
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../../../src/modules/dashboards/widgets/dashboard/sales-by-region/widget.client.tsx"],
|
|
4
|
-
"sourcesContent": ["\"use client\"\n\nimport * as React from 'react'\nimport type { DashboardWidgetComponentProps } from '@open-mercato/shared/modules/dashboard/widgets'\nimport {
|
|
5
|
-
"mappings": ";
|
|
4
|
+
"sourcesContent": ["\"use client\"\n\nimport * as React from 'react'\nimport type { DashboardWidgetComponentProps } from '@open-mercato/shared/modules/dashboard/widgets'\nimport { useWidgetData, type WidgetDataFetcher } from '@open-mercato/ui/backend/dashboard/widgetData'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport { BarChart, type BarChartDataItem } from '@open-mercato/ui/backend/charts'\nimport { DateRangeSelect, type DateRangePreset } from '@open-mercato/ui/backend/date-range'\nimport { Input } from '@open-mercato/ui/primitives/input'\nimport { DEFAULT_SETTINGS, hydrateSettings, type SalesByRegionSettings } from './config'\nimport type { WidgetDataResponse } from '../../../services/widgetDataService'\nimport { formatCurrencyCompact } from '../../../lib/formatters'\n\nasync function fetchSalesByRegionData(settings: SalesByRegionSettings, fetchWidgetData: WidgetDataFetcher): Promise<WidgetDataResponse> {\n const body = {\n entityType: 'sales:orders',\n metric: {\n field: 'grandTotalGrossAmount',\n aggregate: 'sum',\n },\n groupBy: {\n field: 'shippingAddressSnapshot.region',\n limit: settings.limit,\n },\n dateRange: {\n field: 'placedAt',\n preset: settings.dateRange,\n },\n }\n\n return fetchWidgetData<WidgetDataResponse>(body)\n}\n\nconst SalesByRegionWidget: React.FC<DashboardWidgetComponentProps<SalesByRegionSettings>> = ({\n mode,\n settings = DEFAULT_SETTINGS,\n onSettingsChange,\n refreshToken,\n onRefreshStateChange,\n}) => {\n const t = useT()\n const hydrated = React.useMemo(() => hydrateSettings(settings), [settings])\n const [data, setData] = React.useState<BarChartDataItem[]>([])\n const [loading, setLoading] = React.useState(true)\n const [error, setError] = React.useState<string | null>(null)\n\n const fetchWidgetData = useWidgetData()\n const refresh = React.useCallback(async () => {\n onRefreshStateChange?.(true)\n setLoading(true)\n setError(null)\n try {\n const result = await fetchSalesByRegionData(hydrated, fetchWidgetData)\n const chartData = result.data.map((item) => ({\n region: String(item.groupKey || t('dashboards.analytics.labels.unknown', 'Unknown')),\n Revenue: item.value ?? 0,\n }))\n setData(chartData)\n } catch (err) {\n console.error('Failed to load sales by region data', err)\n setError(t('dashboards.analytics.widgets.salesByRegion.error', 'Failed to load data'))\n } finally {\n setLoading(false)\n onRefreshStateChange?.(false)\n }\n }, [hydrated, fetchWidgetData, onRefreshStateChange, t])\n\n React.useEffect(() => {\n refresh().catch(() => {})\n }, [refresh, refreshToken])\n\n if (mode === 'settings') {\n return (\n <div className=\"space-y-4 text-sm\">\n <DateRangeSelect\n id=\"sales-by-region-date-range\"\n label={t('dashboards.analytics.settings.dateRange', 'Date Range')}\n value={hydrated.dateRange}\n onChange={(dateRange: DateRangePreset) => onSettingsChange({ ...hydrated, dateRange })}\n />\n <div className=\"space-y-1.5\">\n <label\n htmlFor=\"sales-by-region-limit\"\n className=\"text-xs font-semibold uppercase text-muted-foreground\"\n >\n {t('dashboards.analytics.settings.limit', 'Number of items')}\n </label>\n <Input\n id=\"sales-by-region-limit\"\n type=\"number\"\n min={1}\n max={20}\n className=\"w-24\"\n value={hydrated.limit}\n onChange={(e) => {\n const next = Number(e.target.value)\n onSettingsChange({ ...hydrated, limit: Number.isFinite(next) ? next : hydrated.limit })\n }}\n />\n </div>\n </div>\n )\n }\n\n return (\n <BarChart\n data={data}\n index=\"region\"\n categories={['Revenue']}\n categoryLabels={{ Revenue: t('dashboards.analytics.widgets.topCustomers.column.revenue', 'Revenue') }}\n loading={loading}\n error={error}\n layout=\"horizontal\"\n valueFormatter={formatCurrencyCompact}\n colors={['cyan']}\n showLegend={false}\n emptyMessage={t('dashboards.analytics.widgets.salesByRegion.empty', 'No regional sales data for this period')}\n />\n )\n}\n\nexport default SalesByRegionWidget\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
3
3
|
import * as React from "react";
|
|
4
|
-
import {
|
|
4
|
+
import { useWidgetData } from "@open-mercato/ui/backend/dashboard/widgetData";
|
|
5
5
|
import { useT } from "@open-mercato/shared/lib/i18n/context";
|
|
6
6
|
import { TopNTable } from "@open-mercato/ui/backend/charts";
|
|
7
7
|
import { DateRangeSelect } from "@open-mercato/ui/backend/date-range";
|
|
8
8
|
import { Input } from "@open-mercato/ui/primitives/input";
|
|
9
9
|
import { DEFAULT_SETTINGS, hydrateSettings } from "./config.js";
|
|
10
10
|
import { formatCurrencySafe } from "../../../lib/formatters.js";
|
|
11
|
-
async function fetchTopCustomersData(settings) {
|
|
11
|
+
async function fetchTopCustomersData(settings, fetchWidgetData) {
|
|
12
12
|
const body = {
|
|
13
13
|
entityType: "sales:orders",
|
|
14
14
|
metric: {
|
|
@@ -25,16 +25,7 @@ async function fetchTopCustomersData(settings) {
|
|
|
25
25
|
preset: settings.dateRange
|
|
26
26
|
}
|
|
27
27
|
};
|
|
28
|
-
|
|
29
|
-
method: "POST",
|
|
30
|
-
headers: { "Content-Type": "application/json" },
|
|
31
|
-
body: JSON.stringify(body)
|
|
32
|
-
});
|
|
33
|
-
if (!call.ok) {
|
|
34
|
-
const errorMsg = call.result?.error;
|
|
35
|
-
throw new Error(typeof errorMsg === "string" ? errorMsg : "Failed to fetch top customers data");
|
|
36
|
-
}
|
|
37
|
-
return call.result;
|
|
28
|
+
return fetchWidgetData(body);
|
|
38
29
|
}
|
|
39
30
|
function formatCustomerName(name, unknownLabel) {
|
|
40
31
|
if (!name) return unknownLabel;
|
|
@@ -74,12 +65,13 @@ const TopCustomersWidget = ({
|
|
|
74
65
|
],
|
|
75
66
|
[t, unknownLabel]
|
|
76
67
|
);
|
|
68
|
+
const fetchWidgetData = useWidgetData();
|
|
77
69
|
const refresh = React.useCallback(async () => {
|
|
78
70
|
onRefreshStateChange?.(true);
|
|
79
71
|
setLoading(true);
|
|
80
72
|
setError(null);
|
|
81
73
|
try {
|
|
82
|
-
const result = await fetchTopCustomersData(hydrated);
|
|
74
|
+
const result = await fetchTopCustomersData(hydrated, fetchWidgetData);
|
|
83
75
|
const tableData = result.data.map((item, index) => ({
|
|
84
76
|
rank: index + 1,
|
|
85
77
|
customerId: item.groupLabel || String(item.groupKey || t("dashboards.analytics.labels.unknown", "Unknown")),
|
|
@@ -93,7 +85,7 @@ const TopCustomersWidget = ({
|
|
|
93
85
|
setLoading(false);
|
|
94
86
|
onRefreshStateChange?.(false);
|
|
95
87
|
}
|
|
96
|
-
}, [hydrated, onRefreshStateChange, t]);
|
|
88
|
+
}, [hydrated, fetchWidgetData, onRefreshStateChange, t]);
|
|
97
89
|
React.useEffect(() => {
|
|
98
90
|
refresh().catch(() => {
|
|
99
91
|
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../../../src/modules/dashboards/widgets/dashboard/top-customers/widget.client.tsx"],
|
|
4
|
-
"sourcesContent": ["\"use client\"\n\nimport * as React from 'react'\nimport type { DashboardWidgetComponentProps } from '@open-mercato/shared/modules/dashboard/widgets'\nimport {
|
|
5
|
-
"mappings": ";
|
|
4
|
+
"sourcesContent": ["\"use client\"\n\nimport * as React from 'react'\nimport type { DashboardWidgetComponentProps } from '@open-mercato/shared/modules/dashboard/widgets'\nimport { useWidgetData, type WidgetDataFetcher } from '@open-mercato/ui/backend/dashboard/widgetData'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport { TopNTable, type TopNTableColumn } from '@open-mercato/ui/backend/charts'\nimport { DateRangeSelect, type DateRangePreset } from '@open-mercato/ui/backend/date-range'\nimport { Input } from '@open-mercato/ui/primitives/input'\nimport { DEFAULT_SETTINGS, hydrateSettings, type TopCustomersSettings } from './config'\nimport type { WidgetDataResponse } from '../../../services/widgetDataService'\nimport { formatCurrencySafe } from '../../../lib/formatters'\n\ntype CustomerRow = {\n rank: number\n customerId: string\n revenue: number\n}\n\nasync function fetchTopCustomersData(settings: TopCustomersSettings, fetchWidgetData: WidgetDataFetcher): Promise<WidgetDataResponse> {\n const body = {\n entityType: 'sales:orders',\n metric: {\n field: 'grandTotalGrossAmount',\n aggregate: 'sum',\n },\n groupBy: {\n field: 'customerEntityId',\n limit: settings.limit,\n resolveLabels: true,\n },\n dateRange: {\n field: 'placedAt',\n preset: settings.dateRange,\n },\n }\n\n return fetchWidgetData<WidgetDataResponse>(body)\n}\n\nfunction formatCustomerName(name: string | null, unknownLabel: string): string {\n if (!name) return unknownLabel\n return name\n}\n\nconst TopCustomersWidget: React.FC<DashboardWidgetComponentProps<TopCustomersSettings>> = ({\n mode,\n settings = DEFAULT_SETTINGS,\n onSettingsChange,\n refreshToken,\n onRefreshStateChange,\n}) => {\n const t = useT()\n const hydrated = React.useMemo(() => hydrateSettings(settings), [settings])\n const [data, setData] = React.useState<CustomerRow[]>([])\n const [loading, setLoading] = React.useState(true)\n const [error, setError] = React.useState<string | null>(null)\n\n const unknownLabel = t('dashboards.analytics.labels.unknown', 'Unknown')\n const columns: TopNTableColumn<CustomerRow>[] = React.useMemo(\n () => [\n {\n key: 'rank',\n header: '#',\n width: '40px',\n },\n {\n key: 'customerId',\n header: t('dashboards.analytics.widgets.topCustomers.column.customer', 'Customer'),\n formatter: (value) => formatCustomerName(String(value || ''), unknownLabel),\n },\n {\n key: 'revenue',\n header: t('dashboards.analytics.widgets.topCustomers.column.revenue', 'Revenue'),\n align: 'right',\n formatter: (value: unknown) => formatCurrencySafe(value),\n },\n ],\n [t, unknownLabel],\n )\n\n const fetchWidgetData = useWidgetData()\n const refresh = React.useCallback(async () => {\n onRefreshStateChange?.(true)\n setLoading(true)\n setError(null)\n try {\n const result = await fetchTopCustomersData(hydrated, fetchWidgetData)\n const tableData: CustomerRow[] = result.data.map((item, index) => ({\n rank: index + 1,\n customerId: item.groupLabel || String(item.groupKey || t('dashboards.analytics.labels.unknown', 'Unknown')),\n revenue: item.value ?? 0,\n }))\n setData(tableData)\n } catch (err) {\n console.error('Failed to load top customers data', err)\n setError(t('dashboards.analytics.widgets.topCustomers.error', 'Failed to load data'))\n } finally {\n setLoading(false)\n onRefreshStateChange?.(false)\n }\n }, [hydrated, fetchWidgetData, onRefreshStateChange, t])\n\n React.useEffect(() => {\n refresh().catch(() => {})\n }, [refresh, refreshToken])\n\n if (mode === 'settings') {\n return (\n <div className=\"space-y-4 text-sm\">\n <DateRangeSelect\n id=\"top-customers-date-range\"\n label={t('dashboards.analytics.settings.dateRange', 'Date Range')}\n value={hydrated.dateRange}\n onChange={(dateRange: DateRangePreset) => onSettingsChange({ ...hydrated, dateRange })}\n />\n <div className=\"space-y-1.5\">\n <label\n htmlFor=\"top-customers-limit\"\n className=\"text-xs font-semibold uppercase text-muted-foreground\"\n >\n {t('dashboards.analytics.settings.limit', 'Number of items')}\n </label>\n <Input\n id=\"top-customers-limit\"\n type=\"number\"\n min={1}\n max={20}\n className=\"w-24\"\n value={hydrated.limit}\n onChange={(e) => {\n const next = Number(e.target.value)\n onSettingsChange({ ...hydrated, limit: Number.isFinite(next) ? next : hydrated.limit })\n }}\n />\n </div>\n </div>\n )\n }\n\n return (\n <TopNTable\n data={data}\n columns={columns}\n loading={loading}\n error={error}\n emptyMessage={t('dashboards.analytics.widgets.topCustomers.empty', 'No customer data for this period')}\n />\n )\n}\n\nexport default TopCustomersWidget\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
3
3
|
import * as React from "react";
|
|
4
|
-
import {
|
|
4
|
+
import { useWidgetData } from "@open-mercato/ui/backend/dashboard/widgetData";
|
|
5
5
|
import { useT } from "@open-mercato/shared/lib/i18n/context";
|
|
6
6
|
import { BarChart } from "@open-mercato/ui/backend/charts";
|
|
7
7
|
import { DateRangeSelect, InlineDateRangeSelect } from "@open-mercato/ui/backend/date-range";
|
|
@@ -15,7 +15,7 @@ import {
|
|
|
15
15
|
} from "@open-mercato/ui/primitives/select";
|
|
16
16
|
import { DEFAULT_SETTINGS, hydrateSettings } from "./config.js";
|
|
17
17
|
import { formatCurrencyCompact } from "../../../lib/formatters.js";
|
|
18
|
-
async function fetchTopProductsData(settings) {
|
|
18
|
+
async function fetchTopProductsData(settings, fetchWidgetData) {
|
|
19
19
|
const body = {
|
|
20
20
|
entityType: "sales:order_lines",
|
|
21
21
|
metric: {
|
|
@@ -32,16 +32,7 @@ async function fetchTopProductsData(settings) {
|
|
|
32
32
|
preset: settings.dateRange
|
|
33
33
|
}
|
|
34
34
|
};
|
|
35
|
-
|
|
36
|
-
method: "POST",
|
|
37
|
-
headers: { "Content-Type": "application/json" },
|
|
38
|
-
body: JSON.stringify(body)
|
|
39
|
-
});
|
|
40
|
-
if (!call.ok) {
|
|
41
|
-
const errorMsg = call.result?.error;
|
|
42
|
-
throw new Error(typeof errorMsg === "string" ? errorMsg : "Failed to fetch top products data");
|
|
43
|
-
}
|
|
44
|
-
return call.result;
|
|
35
|
+
return fetchWidgetData(body);
|
|
45
36
|
}
|
|
46
37
|
function truncateLabel(label, t, maxLength = 20) {
|
|
47
38
|
if (label == null || label === "") return t("dashboards.analytics.labels.unknownProduct", "Unknown Product");
|
|
@@ -68,6 +59,7 @@ const TopProductsWidget = ({
|
|
|
68
59
|
const [loading, setLoading] = React.useState(true);
|
|
69
60
|
const [error, setError] = React.useState(null);
|
|
70
61
|
const fetchingRef = React.useRef(false);
|
|
62
|
+
const fetchWidgetData = useWidgetData();
|
|
71
63
|
const refresh = React.useCallback(async () => {
|
|
72
64
|
if (fetchingRef.current) return;
|
|
73
65
|
fetchingRef.current = true;
|
|
@@ -75,7 +67,7 @@ const TopProductsWidget = ({
|
|
|
75
67
|
setLoading(true);
|
|
76
68
|
setError(null);
|
|
77
69
|
try {
|
|
78
|
-
const result = await fetchTopProductsData(hydrated);
|
|
70
|
+
const result = await fetchTopProductsData(hydrated, fetchWidgetData);
|
|
79
71
|
const chartData = result.data.map((item, index) => ({
|
|
80
72
|
name: truncateLabel(item.groupLabel ?? item.groupKey ?? `Product ${index + 1}`, t),
|
|
81
73
|
Revenue: item.value ?? 0
|
|
@@ -89,7 +81,7 @@ const TopProductsWidget = ({
|
|
|
89
81
|
onRefreshStateChange?.(false);
|
|
90
82
|
fetchingRef.current = false;
|
|
91
83
|
}
|
|
92
|
-
}, [hydrated, onRefreshStateChange, t]);
|
|
84
|
+
}, [hydrated, fetchWidgetData, onRefreshStateChange, t]);
|
|
93
85
|
React.useEffect(() => {
|
|
94
86
|
refresh().catch(() => {
|
|
95
87
|
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../../../src/modules/dashboards/widgets/dashboard/top-products/widget.client.tsx"],
|
|
4
|
-
"sourcesContent": ["\"use client\"\n\nimport * as React from 'react'\nimport type { DashboardWidgetComponentProps } from '@open-mercato/shared/modules/dashboard/widgets'\nimport {
|
|
5
|
-
"mappings": ";
|
|
4
|
+
"sourcesContent": ["\"use client\"\n\nimport * as React from 'react'\nimport type { DashboardWidgetComponentProps } from '@open-mercato/shared/modules/dashboard/widgets'\nimport { useWidgetData, type WidgetDataFetcher } from '@open-mercato/ui/backend/dashboard/widgetData'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport { BarChart, type BarChartDataItem } from '@open-mercato/ui/backend/charts'\nimport { DateRangeSelect, InlineDateRangeSelect, type DateRangePreset } from '@open-mercato/ui/backend/date-range'\nimport { Input } from '@open-mercato/ui/primitives/input'\nimport {\n Select,\n SelectContent,\n SelectItem,\n SelectTrigger,\n SelectValue,\n} from '@open-mercato/ui/primitives/select'\nimport { DEFAULT_SETTINGS, hydrateSettings, type TopProductsSettings } from './config'\nimport type { WidgetDataResponse } from '../../../services/widgetDataService'\nimport { formatCurrencyCompact } from '../../../lib/formatters'\n\nasync function fetchTopProductsData(settings: TopProductsSettings, fetchWidgetData: WidgetDataFetcher): Promise<WidgetDataResponse> {\n const body = {\n entityType: 'sales:order_lines',\n metric: {\n field: 'totalGrossAmount',\n aggregate: 'sum',\n },\n groupBy: {\n field: 'productId',\n limit: settings.limit,\n resolveLabels: true,\n },\n dateRange: {\n field: 'createdAt',\n preset: settings.dateRange,\n },\n }\n\n return fetchWidgetData<WidgetDataResponse>(body)\n}\n\nfunction truncateLabel(\n label: unknown,\n t: (key: string, fallback: string) => string,\n maxLength: number = 20\n): string {\n if (label == null || label === '') return t('dashboards.analytics.labels.unknownProduct', 'Unknown Product')\n const labelStr = String(label)\n // Check for UUID-like strings or meaningless values\n if (labelStr === '0' || labelStr === 'null' || labelStr === 'undefined') {\n return t('dashboards.analytics.labels.unknownProduct', 'Unknown Product')\n }\n if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(labelStr)) {\n return t('dashboards.analytics.labels.unnamedProduct', 'Unnamed Product')\n }\n if (labelStr.length <= maxLength) return labelStr\n return labelStr.slice(0, maxLength - 3) + '...'\n}\n\nconst TopProductsWidget: React.FC<DashboardWidgetComponentProps<TopProductsSettings>> = ({\n mode,\n settings = DEFAULT_SETTINGS,\n onSettingsChange,\n refreshToken,\n onRefreshStateChange,\n}) => {\n const t = useT()\n const hydrated = React.useMemo(() => hydrateSettings(settings), [settings])\n const [data, setData] = React.useState<BarChartDataItem[]>([])\n const [loading, setLoading] = React.useState(true)\n const [error, setError] = React.useState<string | null>(null)\n const fetchingRef = React.useRef(false)\n\n const fetchWidgetData = useWidgetData()\n const refresh = React.useCallback(async () => {\n if (fetchingRef.current) return\n fetchingRef.current = true\n onRefreshStateChange?.(true)\n setLoading(true)\n setError(null)\n try {\n const result = await fetchTopProductsData(hydrated, fetchWidgetData)\n const chartData = result.data.map((item, index) => ({\n name: truncateLabel(item.groupLabel ?? item.groupKey ?? `Product ${index + 1}`, t),\n Revenue: item.value ?? 0,\n }))\n setData(chartData)\n } catch (err) {\n console.error('Failed to load top products data', err)\n setError(t('dashboards.analytics.widgets.topProducts.error', 'Failed to load data'))\n } finally {\n setLoading(false)\n onRefreshStateChange?.(false)\n fetchingRef.current = false\n }\n }, [hydrated, fetchWidgetData, onRefreshStateChange, t])\n\n React.useEffect(() => {\n refresh().catch(() => {})\n }, [refresh, refreshToken])\n\n if (mode === 'settings') {\n return (\n <div className=\"space-y-4 text-sm\">\n <DateRangeSelect\n id=\"top-products-date-range\"\n label={t('dashboards.analytics.settings.dateRange', 'Date Range')}\n value={hydrated.dateRange}\n onChange={(dateRange: DateRangePreset) => onSettingsChange({ ...hydrated, dateRange })}\n />\n <div className=\"space-y-1.5\">\n <label\n htmlFor=\"top-products-limit\"\n className=\"text-xs font-semibold uppercase text-muted-foreground\"\n >\n {t('dashboards.analytics.settings.limit', 'Number of items')}\n </label>\n <Input\n id=\"top-products-limit\"\n type=\"number\"\n min={1}\n max={20}\n className=\"w-24\"\n value={hydrated.limit}\n onChange={(e) => {\n const next = Number(e.target.value)\n onSettingsChange({ ...hydrated, limit: Number.isFinite(next) ? next : hydrated.limit })\n }}\n />\n </div>\n <div className=\"space-y-1.5\">\n <label\n htmlFor=\"top-products-layout\"\n className=\"text-xs font-semibold uppercase text-muted-foreground\"\n >\n {t('dashboards.analytics.settings.chartLayout', 'Chart Layout')}\n </label>\n <Select\n value={hydrated.layout}\n onValueChange={(value) => onSettingsChange({ ...hydrated, layout: value as 'horizontal' | 'vertical' })}\n >\n <SelectTrigger id=\"top-products-layout\" size=\"sm\">\n <SelectValue />\n </SelectTrigger>\n <SelectContent>\n <SelectItem value=\"horizontal\">{t('dashboards.analytics.settings.horizontal', 'Horizontal')}</SelectItem>\n <SelectItem value=\"vertical\">{t('dashboards.analytics.settings.vertical', 'Vertical')}</SelectItem>\n </SelectContent>\n </Select>\n </div>\n </div>\n )\n }\n\n return (\n <div className=\"flex flex-col h-full\">\n <div className=\"flex justify-end mb-2\">\n <InlineDateRangeSelect\n value={hydrated.dateRange}\n onChange={(dateRange) => onSettingsChange({ ...hydrated, dateRange })}\n />\n </div>\n <div className=\"flex-1 min-h-0\">\n <BarChart\n data={data}\n index=\"name\"\n categories={['Revenue']}\n categoryLabels={{ Revenue: t('dashboards.analytics.widgets.topCustomers.column.revenue', 'Revenue') }}\n loading={loading}\n error={error}\n layout={hydrated.layout}\n valueFormatter={formatCurrencyCompact}\n colors={['emerald']}\n showLegend={false}\n emptyMessage={t('dashboards.analytics.widgets.topProducts.empty', 'No product sales data for this period')}\n />\n </div>\n </div>\n )\n}\n\nexport default TopProductsWidget\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/core",
|
|
3
|
-
"version": "0.6.4-develop.
|
|
3
|
+
"version": "0.6.4-develop.4133.1.48fc6c8f7b",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"scripts": {
|
|
@@ -243,16 +243,16 @@
|
|
|
243
243
|
"zod": "^4.4.3"
|
|
244
244
|
},
|
|
245
245
|
"peerDependencies": {
|
|
246
|
-
"@open-mercato/ai-assistant": "0.6.4-develop.
|
|
247
|
-
"@open-mercato/shared": "0.6.4-develop.
|
|
248
|
-
"@open-mercato/ui": "0.6.4-develop.
|
|
246
|
+
"@open-mercato/ai-assistant": "0.6.4-develop.4133.1.48fc6c8f7b",
|
|
247
|
+
"@open-mercato/shared": "0.6.4-develop.4133.1.48fc6c8f7b",
|
|
248
|
+
"@open-mercato/ui": "0.6.4-develop.4133.1.48fc6c8f7b",
|
|
249
249
|
"react": "^19.0.0",
|
|
250
250
|
"react-dom": "^19.0.0"
|
|
251
251
|
},
|
|
252
252
|
"devDependencies": {
|
|
253
|
-
"@open-mercato/ai-assistant": "0.6.4-develop.
|
|
254
|
-
"@open-mercato/shared": "0.6.4-develop.
|
|
255
|
-
"@open-mercato/ui": "0.6.4-develop.
|
|
253
|
+
"@open-mercato/ai-assistant": "0.6.4-develop.4133.1.48fc6c8f7b",
|
|
254
|
+
"@open-mercato/shared": "0.6.4-develop.4133.1.48fc6c8f7b",
|
|
255
|
+
"@open-mercato/ui": "0.6.4-develop.4133.1.48fc6c8f7b",
|
|
256
256
|
"@testing-library/dom": "^10.4.1",
|
|
257
257
|
"@testing-library/jest-dom": "^6.9.1",
|
|
258
258
|
"@testing-library/react": "^16.3.1",
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server'
|
|
2
|
+
import { z } from 'zod'
|
|
3
|
+
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
4
|
+
import type { CacheStrategy } from '@open-mercato/cache'
|
|
5
|
+
import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
|
|
6
|
+
import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
|
|
7
|
+
import { resolveOrganizationScopeForRequest } from '@open-mercato/core/modules/directory/utils/organizationScope'
|
|
8
|
+
import {
|
|
9
|
+
createWidgetDataService,
|
|
10
|
+
type WidgetDataRequest,
|
|
11
|
+
WidgetDataValidationError,
|
|
12
|
+
} from '../../../../services/widgetDataService'
|
|
13
|
+
import { runWidgetDataBatch } from '../../../../lib/widgetDataBatch'
|
|
14
|
+
import type { AnalyticsRegistry } from '../../../../services/analyticsRegistry'
|
|
15
|
+
import type { OpenApiMethodDoc, OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
|
|
16
|
+
import { dashboardsTag, dashboardsErrorSchema } from '../../../openapi'
|
|
17
|
+
import { widgetDataRequestSchema, widgetDataResponseSchema } from '../schema'
|
|
18
|
+
|
|
19
|
+
export const metadata = {
|
|
20
|
+
POST: { requireAuth: true, requireFeatures: ['analytics.view'] },
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const MAX_BATCH_SIZE = 50
|
|
24
|
+
|
|
25
|
+
const widgetDataBatchRequestSchema = z.object({
|
|
26
|
+
requests: z
|
|
27
|
+
.array(
|
|
28
|
+
z.object({
|
|
29
|
+
id: z.string().min(1),
|
|
30
|
+
request: widgetDataRequestSchema,
|
|
31
|
+
}),
|
|
32
|
+
)
|
|
33
|
+
.min(1)
|
|
34
|
+
.max(MAX_BATCH_SIZE),
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
const widgetDataBatchResponseSchema = z.object({
|
|
38
|
+
results: z.array(
|
|
39
|
+
z.discriminatedUnion('ok', [
|
|
40
|
+
z.object({
|
|
41
|
+
id: z.string(),
|
|
42
|
+
ok: z.literal(true),
|
|
43
|
+
data: widgetDataResponseSchema,
|
|
44
|
+
}),
|
|
45
|
+
z.object({
|
|
46
|
+
id: z.string(),
|
|
47
|
+
ok: z.literal(false),
|
|
48
|
+
error: z.string(),
|
|
49
|
+
}),
|
|
50
|
+
]),
|
|
51
|
+
),
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
export async function POST(req: Request) {
|
|
55
|
+
const auth = await getAuthFromRequest(req)
|
|
56
|
+
if (!auth) {
|
|
57
|
+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
let body: unknown
|
|
61
|
+
try {
|
|
62
|
+
body = await req.json()
|
|
63
|
+
} catch {
|
|
64
|
+
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const parsed = widgetDataBatchRequestSchema.safeParse(body)
|
|
68
|
+
if (!parsed.success) {
|
|
69
|
+
return NextResponse.json(
|
|
70
|
+
{ error: 'Invalid request payload', issues: parsed.error.issues },
|
|
71
|
+
{ status: 400 },
|
|
72
|
+
)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const tenantId = auth.tenantId ?? null
|
|
76
|
+
if (!tenantId) {
|
|
77
|
+
return NextResponse.json({ error: 'Tenant context is required' }, { status: 400 })
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Build the per-request DI/RBAC/org-scope stack exactly once for the whole
|
|
81
|
+
// batch instead of once per widget (see issue #2273).
|
|
82
|
+
const container = await createRequestContainer()
|
|
83
|
+
const analyticsRegistry = container.resolve<AnalyticsRegistry>('analyticsRegistry')
|
|
84
|
+
|
|
85
|
+
const em = (container.resolve('em') as EntityManager).fork({
|
|
86
|
+
clear: true,
|
|
87
|
+
freshEventManager: true,
|
|
88
|
+
useContext: true,
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
const scope = await resolveOrganizationScopeForRequest({ container, auth, request: req })
|
|
92
|
+
|
|
93
|
+
const organizationIds = (() => {
|
|
94
|
+
if (scope?.selectedId) return [scope.selectedId]
|
|
95
|
+
if (Array.isArray(scope?.filterIds) && scope.filterIds.length > 0) return scope.filterIds
|
|
96
|
+
if (scope?.allowedIds === null) return undefined
|
|
97
|
+
if (auth.orgId) return [auth.orgId]
|
|
98
|
+
return undefined
|
|
99
|
+
})()
|
|
100
|
+
|
|
101
|
+
const cache = container.resolve<CacheStrategy>('cache')
|
|
102
|
+
const service = createWidgetDataService(em, { tenantId, organizationIds }, analyticsRegistry, cache)
|
|
103
|
+
|
|
104
|
+
const rbacService = container.resolve<{
|
|
105
|
+
userHasAllFeatures: (
|
|
106
|
+
userId: string,
|
|
107
|
+
features: string[],
|
|
108
|
+
scope: { tenantId: string; organizationId?: string | null },
|
|
109
|
+
) => Promise<boolean>
|
|
110
|
+
}>('rbacService')
|
|
111
|
+
|
|
112
|
+
try {
|
|
113
|
+
const results = await runWidgetDataBatch(parsed.data.requests as Array<{ id: string; request: WidgetDataRequest }>, {
|
|
114
|
+
getRequiredFeatures: (entityType) => analyticsRegistry.getRequiredFeatures(entityType),
|
|
115
|
+
checkFeatures: (features) => {
|
|
116
|
+
if (features.length === 0) return Promise.resolve(true)
|
|
117
|
+
return rbacService.userHasAllFeatures(auth.sub, features, {
|
|
118
|
+
tenantId,
|
|
119
|
+
organizationId: auth.orgId,
|
|
120
|
+
})
|
|
121
|
+
},
|
|
122
|
+
fetchOne: (request) => service.fetchWidgetData(request),
|
|
123
|
+
describeError: (error) =>
|
|
124
|
+
error instanceof WidgetDataValidationError
|
|
125
|
+
? error.message
|
|
126
|
+
: 'An error occurred while processing your request',
|
|
127
|
+
})
|
|
128
|
+
return NextResponse.json({ results })
|
|
129
|
+
} catch (err) {
|
|
130
|
+
console.error('[widgets/data/batch] Error:', err)
|
|
131
|
+
return NextResponse.json(
|
|
132
|
+
{ error: 'An error occurred while processing your request' },
|
|
133
|
+
{ status: 500 },
|
|
134
|
+
)
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const widgetDataBatchPostDoc: OpenApiMethodDoc = {
|
|
139
|
+
summary: 'Fetch aggregated data for multiple dashboard widgets in one request',
|
|
140
|
+
description:
|
|
141
|
+
'Resolves a batch of widget data requests with a single authentication, RBAC, organization-scope, and database-context setup. Each request is keyed by an opaque widget id and resolved independently, so a failure in one widget does not fail the batch.',
|
|
142
|
+
tags: [dashboardsTag],
|
|
143
|
+
requestBody: {
|
|
144
|
+
contentType: 'application/json',
|
|
145
|
+
schema: widgetDataBatchRequestSchema,
|
|
146
|
+
description: 'A list of id-keyed widget data requests to resolve together.',
|
|
147
|
+
},
|
|
148
|
+
responses: [
|
|
149
|
+
{
|
|
150
|
+
status: 200,
|
|
151
|
+
description: 'Per-widget aggregation results keyed by request id.',
|
|
152
|
+
schema: widgetDataBatchResponseSchema,
|
|
153
|
+
},
|
|
154
|
+
],
|
|
155
|
+
errors: [
|
|
156
|
+
{ status: 400, description: 'Invalid request payload', schema: dashboardsErrorSchema },
|
|
157
|
+
{ status: 401, description: 'Authentication required', schema: dashboardsErrorSchema },
|
|
158
|
+
{ status: 500, description: 'Internal server error', schema: dashboardsErrorSchema },
|
|
159
|
+
],
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export const openApi: OpenApiRouteDoc = {
|
|
163
|
+
tag: dashboardsTag,
|
|
164
|
+
summary: 'Batch widget data aggregation endpoint',
|
|
165
|
+
methods: {
|
|
166
|
+
POST: widgetDataBatchPostDoc,
|
|
167
|
+
},
|
|
168
|
+
}
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { NextResponse } from 'next/server'
|
|
2
|
-
import { z } from 'zod'
|
|
3
2
|
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
4
3
|
import type { CacheStrategy } from '@open-mercato/cache'
|
|
5
4
|
import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
|
|
@@ -13,100 +12,12 @@ import {
|
|
|
13
12
|
import type { AnalyticsRegistry } from '../../../services/analyticsRegistry'
|
|
14
13
|
import type { OpenApiMethodDoc, OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
|
|
15
14
|
import { dashboardsTag, dashboardsErrorSchema } from '../../openapi'
|
|
15
|
+
import { widgetDataRequestSchema, widgetDataResponseSchema } from './schema'
|
|
16
16
|
|
|
17
17
|
export const metadata = {
|
|
18
18
|
POST: { requireAuth: true, requireFeatures: ['analytics.view'] },
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
const aggregateFunctionSchema = z.enum(['count', 'sum', 'avg', 'min', 'max'])
|
|
22
|
-
const dateGranularitySchema = z.enum(['day', 'week', 'month', 'quarter', 'year'])
|
|
23
|
-
const dateRangePresetSchema = z.enum([
|
|
24
|
-
'today',
|
|
25
|
-
'yesterday',
|
|
26
|
-
'this_week',
|
|
27
|
-
'last_week',
|
|
28
|
-
'this_month',
|
|
29
|
-
'last_month',
|
|
30
|
-
'this_quarter',
|
|
31
|
-
'last_quarter',
|
|
32
|
-
'this_year',
|
|
33
|
-
'last_year',
|
|
34
|
-
'last_7_days',
|
|
35
|
-
'last_30_days',
|
|
36
|
-
'last_90_days',
|
|
37
|
-
])
|
|
38
|
-
|
|
39
|
-
const filterOperatorSchema = z.enum([
|
|
40
|
-
'eq',
|
|
41
|
-
'neq',
|
|
42
|
-
'gt',
|
|
43
|
-
'gte',
|
|
44
|
-
'lt',
|
|
45
|
-
'lte',
|
|
46
|
-
'in',
|
|
47
|
-
'not_in',
|
|
48
|
-
'is_null',
|
|
49
|
-
'is_not_null',
|
|
50
|
-
])
|
|
51
|
-
|
|
52
|
-
const widgetDataRequestSchema = z.object({
|
|
53
|
-
entityType: z.string().min(1),
|
|
54
|
-
metric: z.object({
|
|
55
|
-
field: z.string().min(1),
|
|
56
|
-
aggregate: aggregateFunctionSchema,
|
|
57
|
-
}),
|
|
58
|
-
groupBy: z
|
|
59
|
-
.object({
|
|
60
|
-
field: z.string().min(1),
|
|
61
|
-
granularity: dateGranularitySchema.optional(),
|
|
62
|
-
limit: z.number().int().min(1).max(100).optional(),
|
|
63
|
-
resolveLabels: z.boolean().optional(),
|
|
64
|
-
})
|
|
65
|
-
.optional(),
|
|
66
|
-
filters: z
|
|
67
|
-
.array(
|
|
68
|
-
z.object({
|
|
69
|
-
field: z.string().min(1),
|
|
70
|
-
operator: filterOperatorSchema,
|
|
71
|
-
value: z.unknown().optional(),
|
|
72
|
-
}),
|
|
73
|
-
)
|
|
74
|
-
.optional(),
|
|
75
|
-
dateRange: z
|
|
76
|
-
.object({
|
|
77
|
-
field: z.string().min(1),
|
|
78
|
-
preset: dateRangePresetSchema,
|
|
79
|
-
})
|
|
80
|
-
.optional(),
|
|
81
|
-
comparison: z
|
|
82
|
-
.object({
|
|
83
|
-
type: z.enum(['previous_period', 'previous_year']),
|
|
84
|
-
})
|
|
85
|
-
.optional(),
|
|
86
|
-
})
|
|
87
|
-
|
|
88
|
-
const widgetDataItemSchema = z.object({
|
|
89
|
-
groupKey: z.unknown(),
|
|
90
|
-
groupLabel: z.string().optional(),
|
|
91
|
-
value: z.number().nullable(),
|
|
92
|
-
})
|
|
93
|
-
|
|
94
|
-
const widgetDataResponseSchema = z.object({
|
|
95
|
-
value: z.number().nullable(),
|
|
96
|
-
data: z.array(widgetDataItemSchema),
|
|
97
|
-
comparison: z
|
|
98
|
-
.object({
|
|
99
|
-
value: z.number().nullable(),
|
|
100
|
-
change: z.number(),
|
|
101
|
-
direction: z.enum(['up', 'down', 'unchanged']),
|
|
102
|
-
})
|
|
103
|
-
.optional(),
|
|
104
|
-
metadata: z.object({
|
|
105
|
-
fetchedAt: z.string(),
|
|
106
|
-
recordCount: z.number(),
|
|
107
|
-
}),
|
|
108
|
-
})
|
|
109
|
-
|
|
110
21
|
export async function POST(req: Request) {
|
|
111
22
|
const auth = await getAuthFromRequest(req)
|
|
112
23
|
if (!auth) {
|