@teincfood/core 0.10.12 → 0.10.14
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.
|
@@ -87,8 +87,14 @@ export declare const dashboardService: {
|
|
|
87
87
|
fetchSellerDashboard: (_businessId: string) => Promise<unknown>;
|
|
88
88
|
};
|
|
89
89
|
export declare const analyticsService: {
|
|
90
|
-
fetchSellerAnalytics: (_businessId: string, _period?: string
|
|
91
|
-
|
|
90
|
+
fetchSellerAnalytics: (_businessId: string, _period?: string, _range?: {
|
|
91
|
+
date_from?: string;
|
|
92
|
+
date_to?: string;
|
|
93
|
+
}) => Promise<unknown>;
|
|
94
|
+
fetchRevenueSummary: (_businessId: string, _period?: string, _range?: {
|
|
95
|
+
date_from?: string;
|
|
96
|
+
date_to?: string;
|
|
97
|
+
}) => Promise<unknown>;
|
|
92
98
|
};
|
|
93
99
|
export declare const placesService: {
|
|
94
100
|
searchPlaces: (_query: string, _lat?: number, _lng?: number) => Promise<PlaceSuggestion[]>;
|
|
@@ -172,10 +172,10 @@ export const dashboardService = {
|
|
|
172
172
|
};
|
|
173
173
|
// analytics.service — seller analytics + revenue (hosts inject axios impls)
|
|
174
174
|
export const analyticsService = {
|
|
175
|
-
fetchSellerAnalytics: async (_businessId, _period) => {
|
|
175
|
+
fetchSellerAnalytics: async (_businessId, _period, _range) => {
|
|
176
176
|
notImpl("analyticsService.fetchSellerAnalytics");
|
|
177
177
|
},
|
|
178
|
-
fetchRevenueSummary: async (_businessId, _period) => {
|
|
178
|
+
fetchRevenueSummary: async (_businessId, _period, _range) => {
|
|
179
179
|
notImpl("analyticsService.fetchRevenueSummary");
|
|
180
180
|
},
|
|
181
181
|
};
|
|
@@ -3,13 +3,19 @@
|
|
|
3
3
|
* Offline-first: local SQLite aggregates serve instantly; the network
|
|
4
4
|
* refreshes silently when online.
|
|
5
5
|
*/
|
|
6
|
+
export type AnalyticsPeriod = "today" | "this_week" | "this_month" | "this_year" | "custom";
|
|
7
|
+
/** Inclusive UTC day bounds used by the custom range filter. */
|
|
8
|
+
export interface AnalyticsRange {
|
|
9
|
+
date_from?: string;
|
|
10
|
+
date_to?: string;
|
|
11
|
+
}
|
|
6
12
|
export declare const SELLER_ANALYTICS_KEYS: {
|
|
7
13
|
all: readonly ["seller-analytics"];
|
|
8
|
-
analytics: (businessId?: string, period?:
|
|
14
|
+
analytics: (businessId?: string, period?: AnalyticsPeriod, range?: AnalyticsRange) => readonly ["seller-analytics", string | undefined, AnalyticsPeriod | undefined, string | null, string | null];
|
|
9
15
|
};
|
|
10
16
|
export declare const REVENUE_KEYS: {
|
|
11
17
|
all: readonly ["revenue-summary"];
|
|
12
|
-
summary: (businessId?: string, period?:
|
|
18
|
+
summary: (businessId?: string, period?: AnalyticsPeriod, range?: AnalyticsRange) => readonly ["revenue-summary", string | undefined, AnalyticsPeriod | undefined, string | null, string | null];
|
|
13
19
|
};
|
|
14
20
|
interface LocalOrderRow {
|
|
15
21
|
status: string;
|
|
@@ -31,8 +37,21 @@ interface LocalOrderRow {
|
|
|
31
37
|
interface AggregateOptions {
|
|
32
38
|
forRevenueSummary: boolean;
|
|
33
39
|
}
|
|
34
|
-
|
|
35
|
-
|
|
40
|
+
/**
|
|
41
|
+
* Filter local rows to the same period boundaries as the backend
|
|
42
|
+
* (`period_bounds/2`):
|
|
43
|
+
* - today = current UTC day
|
|
44
|
+
* - this_week = Monday 00:00 UTC → now (backend uses ISO weekday)
|
|
45
|
+
* - this_month = 1st of the current UTC month → now
|
|
46
|
+
* - this_year = 1 January of the current UTC year → now
|
|
47
|
+
* - custom = inclusive `date_from` → `date_to` (YYYY-MM-DD)
|
|
48
|
+
*
|
|
49
|
+
* Exported so the period boundaries can be regression-tested with a fixed
|
|
50
|
+
* clock instead of depending on the day the suite runs.
|
|
51
|
+
*/
|
|
52
|
+
export declare function filterByPeriod(orders: LocalOrderRow[], period: AnalyticsPeriod, now?: Date, range?: AnalyticsRange): LocalOrderRow[];
|
|
53
|
+
export declare function aggregateLocal(orders: LocalOrderRow[], period: AnalyticsPeriod, opts: AggregateOptions, range?: AnalyticsRange): {
|
|
54
|
+
period: AnalyticsPeriod;
|
|
36
55
|
total_revenue_minor: number;
|
|
37
56
|
total_orders: number;
|
|
38
57
|
average_order_value_minor: number;
|
|
@@ -61,7 +80,7 @@ export declare function aggregateLocal(orders: LocalOrderRow[], period: "today"
|
|
|
61
80
|
by_payment_method?: undefined;
|
|
62
81
|
by_order_type?: undefined;
|
|
63
82
|
by_day?: undefined;
|
|
64
|
-
period:
|
|
83
|
+
period: AnalyticsPeriod;
|
|
65
84
|
total_revenue_minor: number;
|
|
66
85
|
total_orders: number;
|
|
67
86
|
average_order_value_minor: number;
|
|
@@ -82,16 +101,6 @@ export declare function aggregateLocal(orders: LocalOrderRow[], period: "today"
|
|
|
82
101
|
}[];
|
|
83
102
|
offline: boolean;
|
|
84
103
|
};
|
|
85
|
-
export declare function useSellerAnalyticsQuery(period?:
|
|
86
|
-
|
|
87
|
-
period?: string;
|
|
88
|
-
revenue_by_day?: unknown[];
|
|
89
|
-
};
|
|
90
|
-
}, Error>;
|
|
91
|
-
export declare function useRevenueSummaryQuery(period?: "today" | "this_week" | "this_month", enabled?: boolean): import("@tanstack/react-query").UseQueryResult<{
|
|
92
|
-
data?: {
|
|
93
|
-
period?: string;
|
|
94
|
-
by_day?: unknown[];
|
|
95
|
-
};
|
|
96
|
-
}, Error>;
|
|
104
|
+
export declare function useSellerAnalyticsQuery(period?: AnalyticsPeriod, enabled?: boolean, range?: AnalyticsRange): import("@tanstack/react-query").UseQueryResult<unknown, Error>;
|
|
105
|
+
export declare function useRevenueSummaryQuery(period?: AnalyticsPeriod, enabled?: boolean, range?: AnalyticsRange): import("@tanstack/react-query").UseQueryResult<unknown, Error>;
|
|
97
106
|
export {};
|
|
@@ -11,11 +11,23 @@ import { listLocalOrders } from "../domain/orders/service";
|
|
|
11
11
|
import { useIsEffectivelyOnline } from "./use-infinite-list";
|
|
12
12
|
export const SELLER_ANALYTICS_KEYS = {
|
|
13
13
|
all: ["seller-analytics"],
|
|
14
|
-
analytics: (businessId, period) => [
|
|
14
|
+
analytics: (businessId, period, range) => [
|
|
15
|
+
...SELLER_ANALYTICS_KEYS.all,
|
|
16
|
+
businessId,
|
|
17
|
+
period,
|
|
18
|
+
range?.date_from ?? null,
|
|
19
|
+
range?.date_to ?? null,
|
|
20
|
+
],
|
|
15
21
|
};
|
|
16
22
|
export const REVENUE_KEYS = {
|
|
17
23
|
all: ["revenue-summary"],
|
|
18
|
-
summary: (businessId, period) => [
|
|
24
|
+
summary: (businessId, period, range) => [
|
|
25
|
+
...REVENUE_KEYS.all,
|
|
26
|
+
businessId,
|
|
27
|
+
period,
|
|
28
|
+
range?.date_from ?? null,
|
|
29
|
+
range?.date_to ?? null,
|
|
30
|
+
],
|
|
19
31
|
};
|
|
20
32
|
function useBusinessId() {
|
|
21
33
|
const id = useSyncExternalStore(subscribeBusiness, () => getActiveBusiness()?.id ?? null, () => null);
|
|
@@ -23,35 +35,54 @@ function useBusinessId() {
|
|
|
23
35
|
}
|
|
24
36
|
const OFFLINE_FIRST = { retry: false, networkMode: "offlineFirst" };
|
|
25
37
|
/**
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
38
|
+
* Filter local rows to the same period boundaries as the backend
|
|
39
|
+
* (`period_bounds/2`):
|
|
40
|
+
* - today = current UTC day
|
|
41
|
+
* - this_week = Monday 00:00 UTC → now (backend uses ISO weekday)
|
|
42
|
+
* - this_month = 1st of the current UTC month → now
|
|
43
|
+
* - this_year = 1 January of the current UTC year → now
|
|
44
|
+
* - custom = inclusive `date_from` → `date_to` (YYYY-MM-DD)
|
|
45
|
+
*
|
|
46
|
+
* Exported so the period boundaries can be regression-tested with a fixed
|
|
47
|
+
* clock instead of depending on the day the suite runs.
|
|
29
48
|
*/
|
|
30
|
-
function filterByPeriod(orders, period) {
|
|
31
|
-
const now = new Date();
|
|
49
|
+
export function filterByPeriod(orders, period, now = new Date(), range) {
|
|
32
50
|
const todayStr = now.toISOString().slice(0, 10);
|
|
33
51
|
if (period === "today") {
|
|
34
52
|
return orders.filter((o) => (o.inserted_at ?? "").slice(0, 10) === todayStr);
|
|
35
53
|
}
|
|
36
|
-
if (period === "
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
54
|
+
if (period === "custom") {
|
|
55
|
+
const from = range?.date_from;
|
|
56
|
+
const to = range?.date_to;
|
|
57
|
+
if (!from || !to)
|
|
58
|
+
return [];
|
|
40
59
|
return orders.filter((o) => {
|
|
41
60
|
const d = (o.inserted_at ?? "").slice(0, 10);
|
|
42
|
-
return d >=
|
|
61
|
+
return d >= from && d <= to;
|
|
43
62
|
});
|
|
44
63
|
}
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
64
|
+
let startStr;
|
|
65
|
+
if (period === "this_week") {
|
|
66
|
+
// `getUTCDay()` is 0 for Sunday; shift so Monday is the first day.
|
|
67
|
+
const todayUtc = new Date(`${todayStr}T00:00:00.000Z`);
|
|
68
|
+
const daysSinceMonday = (todayUtc.getUTCDay() + 6) % 7;
|
|
69
|
+
const startOfWeek = new Date(todayUtc);
|
|
70
|
+
startOfWeek.setUTCDate(todayUtc.getUTCDate() - daysSinceMonday);
|
|
71
|
+
startStr = startOfWeek.toISOString().slice(0, 10);
|
|
72
|
+
}
|
|
73
|
+
else if (period === "this_year") {
|
|
74
|
+
startStr = `${todayStr.slice(0, 4)}-01-01`;
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
startStr = `${todayStr.slice(0, 7)}-01`;
|
|
78
|
+
}
|
|
48
79
|
return orders.filter((o) => {
|
|
49
80
|
const d = (o.inserted_at ?? "").slice(0, 10);
|
|
50
81
|
return d >= startStr && d <= todayStr;
|
|
51
82
|
});
|
|
52
83
|
}
|
|
53
|
-
export function aggregateLocal(orders, period, opts) {
|
|
54
|
-
const filtered = filterByPeriod(orders, period);
|
|
84
|
+
export function aggregateLocal(orders, period, opts, range) {
|
|
85
|
+
const filtered = filterByPeriod(orders, period, new Date(), range);
|
|
55
86
|
// Backend parity (orders.ex get_business_analytics / get_revenue_summary):
|
|
56
87
|
// revenue-eligible = all EXCEPT cancelled. Cancelled orders must never
|
|
57
88
|
// inflate revenue, counts, breakdowns, or top items — online and offline.
|
|
@@ -59,7 +90,9 @@ export function aggregateLocal(orders, period, opts) {
|
|
|
59
90
|
const total_revenue_minor = revenueOrders.reduce((s, o) => s + (o.total_minor ?? 0), 0);
|
|
60
91
|
const total_orders = revenueOrders.length;
|
|
61
92
|
// Backend uses div (truncation) — mirror, don't round.
|
|
62
|
-
const average_order_value_minor = total_orders
|
|
93
|
+
const average_order_value_minor = total_orders
|
|
94
|
+
? Math.floor(total_revenue_minor / total_orders)
|
|
95
|
+
: 0;
|
|
63
96
|
const byMethod = new Map();
|
|
64
97
|
const byType = new Map();
|
|
65
98
|
const byDay = new Map();
|
|
@@ -89,7 +122,9 @@ export function aggregateLocal(orders, period, opts) {
|
|
|
89
122
|
itemRev.set(name, (itemRev.get(name) ?? 0) + rev);
|
|
90
123
|
}
|
|
91
124
|
}
|
|
92
|
-
const pct = (v) =>
|
|
125
|
+
const pct = (v) => total_revenue_minor > 0
|
|
126
|
+
? Math.round((v / total_revenue_minor) * 1000) / 10
|
|
127
|
+
: 0;
|
|
93
128
|
// Backend orders_by_status counts over ALL period orders (not revenue-only).
|
|
94
129
|
const orders_by_status = {
|
|
95
130
|
delivered: filtered.filter((o) => o.status === "delivered").length,
|
|
@@ -103,7 +138,11 @@ export function aggregateLocal(orders, period, opts) {
|
|
|
103
138
|
total_orders,
|
|
104
139
|
average_order_value_minor,
|
|
105
140
|
by_payment_method: [...byMethod.entries()]
|
|
106
|
-
.map(([method, v]) => ({
|
|
141
|
+
.map(([method, v]) => ({
|
|
142
|
+
method,
|
|
143
|
+
...v,
|
|
144
|
+
percentage: pct(v.revenue_minor),
|
|
145
|
+
}))
|
|
107
146
|
.sort((a, b) => b.revenue_minor - a.revenue_minor),
|
|
108
147
|
by_order_type: [...byType.entries()]
|
|
109
148
|
.map(([type, v]) => ({ type, ...v }))
|
|
@@ -121,7 +160,11 @@ export function aggregateLocal(orders, period, opts) {
|
|
|
121
160
|
average_order_value_minor,
|
|
122
161
|
total_refunds_minor: 0,
|
|
123
162
|
top_items: [...itemQty.entries()]
|
|
124
|
-
.map(([name, quantity]) => ({
|
|
163
|
+
.map(([name, quantity]) => ({
|
|
164
|
+
name,
|
|
165
|
+
quantity,
|
|
166
|
+
revenue_minor: itemRev.get(name) ?? 0,
|
|
167
|
+
}))
|
|
125
168
|
.sort((a, b) => b.revenue_minor - a.revenue_minor)
|
|
126
169
|
.slice(0, 10),
|
|
127
170
|
orders_by_status,
|
|
@@ -133,90 +176,78 @@ export function aggregateLocal(orders, period, opts) {
|
|
|
133
176
|
}
|
|
134
177
|
async function localOrders(businessId) {
|
|
135
178
|
const { data } = await listLocalOrders(businessId, {}, 1, 500);
|
|
136
|
-
return
|
|
179
|
+
return data ?? [];
|
|
180
|
+
}
|
|
181
|
+
function hasCustomRange(period, range) {
|
|
182
|
+
return (period !== "custom" || Boolean(range?.date_from && range?.date_to));
|
|
137
183
|
}
|
|
138
|
-
export function useSellerAnalyticsQuery(period = "today", enabled = true) {
|
|
184
|
+
export function useSellerAnalyticsQuery(period = "today", enabled = true, range) {
|
|
139
185
|
const businessId = useBusinessId();
|
|
140
186
|
const online = useIsEffectivelyOnline();
|
|
187
|
+
const customReady = hasCustomRange(period, range);
|
|
141
188
|
return useQuery({
|
|
142
|
-
queryKey: SELLER_ANALYTICS_KEYS.analytics(businessId ?? "", period),
|
|
189
|
+
queryKey: SELLER_ANALYTICS_KEYS.analytics(businessId ?? "", period, range),
|
|
143
190
|
queryFn: async () => {
|
|
144
191
|
if (!online) {
|
|
145
192
|
try {
|
|
146
|
-
return {
|
|
193
|
+
return {
|
|
194
|
+
data: aggregateLocal(await localOrders(businessId), period, { forRevenueSummary: false }, range),
|
|
195
|
+
};
|
|
147
196
|
}
|
|
148
197
|
catch {
|
|
149
|
-
return {
|
|
198
|
+
return {
|
|
199
|
+
data: aggregateLocal([], period, { forRevenueSummary: false }, range),
|
|
200
|
+
};
|
|
150
201
|
}
|
|
151
202
|
}
|
|
152
203
|
try {
|
|
153
|
-
|
|
154
|
-
//
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
const shouldFilterLocally = !byDay?.length ||
|
|
158
|
-
(period === "today" && byDay.length > 1) ||
|
|
159
|
-
(period === "this_week" && byDay.some(d => {
|
|
160
|
-
const day = new Date(d.date ?? "");
|
|
161
|
-
const now = new Date();
|
|
162
|
-
const startOfWeek = new Date(now);
|
|
163
|
-
startOfWeek.setDate(now.getDate() - now.getDay());
|
|
164
|
-
return day < startOfWeek || day > now;
|
|
165
|
-
}));
|
|
166
|
-
if (shouldFilterLocally) {
|
|
167
|
-
const localOrdersData = await localOrders(businessId);
|
|
168
|
-
return { data: aggregateLocal(localOrdersData, period, { forRevenueSummary: false }) };
|
|
169
|
-
}
|
|
170
|
-
return backendRes;
|
|
204
|
+
// The backend applies the period filter itself. An empty
|
|
205
|
+
// `revenue_by_day` is a valid result (e.g. no orders today) and must
|
|
206
|
+
// not be replaced with local rows — doing that hid real backend data.
|
|
207
|
+
return await analyticsService.fetchSellerAnalytics(businessId, period, range);
|
|
171
208
|
}
|
|
172
209
|
catch {
|
|
173
|
-
return {
|
|
210
|
+
return {
|
|
211
|
+
data: aggregateLocal(await localOrders(businessId), period, { forRevenueSummary: false }, range),
|
|
212
|
+
};
|
|
174
213
|
}
|
|
175
214
|
},
|
|
176
|
-
enabled: enabled && Boolean(businessId),
|
|
215
|
+
enabled: enabled && Boolean(businessId) && customReady,
|
|
177
216
|
staleTime: 30000,
|
|
178
217
|
...OFFLINE_FIRST,
|
|
179
218
|
});
|
|
180
219
|
}
|
|
181
|
-
export function useRevenueSummaryQuery(period = "today", enabled = true) {
|
|
220
|
+
export function useRevenueSummaryQuery(period = "today", enabled = true, range) {
|
|
182
221
|
const businessId = useBusinessId();
|
|
183
222
|
const online = useIsEffectivelyOnline();
|
|
223
|
+
const customReady = hasCustomRange(period, range);
|
|
184
224
|
return useQuery({
|
|
185
|
-
queryKey: REVENUE_KEYS.summary(businessId ?? "", period),
|
|
225
|
+
queryKey: REVENUE_KEYS.summary(businessId ?? "", period, range),
|
|
186
226
|
queryFn: async () => {
|
|
187
227
|
if (!online) {
|
|
188
228
|
try {
|
|
189
|
-
return {
|
|
229
|
+
return {
|
|
230
|
+
data: aggregateLocal(await localOrders(businessId), period, { forRevenueSummary: true }, range),
|
|
231
|
+
};
|
|
190
232
|
}
|
|
191
233
|
catch {
|
|
192
|
-
return {
|
|
234
|
+
return {
|
|
235
|
+
data: aggregateLocal([], period, { forRevenueSummary: true }, range),
|
|
236
|
+
};
|
|
193
237
|
}
|
|
194
238
|
}
|
|
195
239
|
try {
|
|
196
|
-
|
|
197
|
-
//
|
|
198
|
-
|
|
199
|
-
const byDay = backendData?.by_day;
|
|
200
|
-
const shouldFilterLocally = !byDay?.length ||
|
|
201
|
-
(period === "today" && byDay.length > 1) ||
|
|
202
|
-
(period === "this_week" && byDay.some(d => {
|
|
203
|
-
const day = new Date(d.date ?? "");
|
|
204
|
-
const now = new Date();
|
|
205
|
-
const startOfWeek = new Date(now);
|
|
206
|
-
startOfWeek.setDate(now.getDate() - now.getDay());
|
|
207
|
-
return day < startOfWeek || day > now;
|
|
208
|
-
}));
|
|
209
|
-
if (shouldFilterLocally) {
|
|
210
|
-
const localOrdersData = await localOrders(businessId);
|
|
211
|
-
return { data: aggregateLocal(localOrdersData, period, { forRevenueSummary: true }) };
|
|
212
|
-
}
|
|
213
|
-
return backendRes;
|
|
240
|
+
// Same contract as analytics: trust the backend period filter and
|
|
241
|
+
// only fall back to SQLite when the request itself fails.
|
|
242
|
+
return await analyticsService.fetchRevenueSummary(businessId, period, range);
|
|
214
243
|
}
|
|
215
244
|
catch {
|
|
216
|
-
return {
|
|
245
|
+
return {
|
|
246
|
+
data: aggregateLocal(await localOrders(businessId), period, { forRevenueSummary: true }, range),
|
|
247
|
+
};
|
|
217
248
|
}
|
|
218
249
|
},
|
|
219
|
-
enabled: enabled && Boolean(businessId),
|
|
250
|
+
enabled: enabled && Boolean(businessId) && customReady,
|
|
220
251
|
staleTime: 30000,
|
|
221
252
|
...OFFLINE_FIRST,
|
|
222
253
|
});
|
package/package.json
CHANGED