@teincfood/core 0.10.14 → 0.10.16

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.
@@ -4,6 +4,9 @@
4
4
  export interface AuthUser {
5
5
  id: string;
6
6
  email?: string;
7
+ full_name?: string | null;
8
+ phone?: string | null;
9
+ avatar_url?: string | null;
7
10
  }
8
11
  export declare function setAuthUser(u: AuthUser | null): void;
9
12
  export declare const useAuthStore: {
@@ -448,4 +448,29 @@ export const MIGRATIONS = [
448
448
  catch { }
449
449
  },
450
450
  },
451
+ {
452
+ version: 13,
453
+ up: async (db) => {
454
+ // Online orders need their prepaid/pay-on-delivery mode locally so the
455
+ // analytics payment breakdown mirrors the backend. POS orders keep
456
+ // `pos_payment_method` as the authoritative method.
457
+ await db.execAsync(`
458
+ ALTER TABLE orders ADD COLUMN payment_mode TEXT;
459
+ `);
460
+ // Best-effort backfill: POS rows are prepaid at the order level; online
461
+ // rows without a value are assumed prepaid until the next pull.
462
+ try {
463
+ await db.execAsync(`
464
+ UPDATE orders
465
+ SET payment_mode = CASE
466
+ WHEN is_pos = 1 THEN 'prepaid'
467
+ WHEN pos_payment_method IS NULL THEN 'prepaid'
468
+ ELSE pos_payment_method
469
+ END
470
+ WHERE payment_mode IS NULL;
471
+ `);
472
+ }
473
+ catch { }
474
+ },
475
+ },
451
476
  ];
@@ -60,6 +60,7 @@ export interface OrderRow {
60
60
  customer_name: string | null;
61
61
  customer_phone: string | null;
62
62
  pos_payment_method: string | null;
63
+ payment_mode: string | null;
63
64
  delivery_id: string | null;
64
65
  business: string | null;
65
66
  user: string | null;
@@ -256,6 +256,7 @@ function orderRowToSellerOrder(row) {
256
256
  customer_name: row.customer_name,
257
257
  customer_phone: row.customer_phone,
258
258
  pos_payment_method: row.pos_payment_method,
259
+ payment_mode: row.payment_mode ?? "prepaid",
259
260
  delivery_id: row.delivery_id,
260
261
  business: row.business ? JSON.parse(row.business) : null,
261
262
  delivery_address: null,
@@ -283,10 +284,10 @@ export async function insertOrder(db, order) {
283
284
  const paidAt = order.paid_at ??
284
285
  order.payment?.paid_at ??
285
286
  (isOrderPaid(order) ? updatedAt : null);
286
- await db.runAsync(`INSERT INTO orders (id, business_id, order_number, callout_number, status, order_type, is_pos, items, subtotal_minor, total_minor, tax_minor, tax_lines, payment, paid_at, customer_name, customer_phone, pos_payment_method, delivery_id, business, user, inserted_at, updated_at, hlc)
287
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
288
- ON CONFLICT(id) DO UPDATE SET business_id=excluded.business_id, order_number=excluded.order_number, callout_number=excluded.callout_number, status=excluded.status, order_type=excluded.order_type, is_pos=excluded.is_pos, items=excluded.items, subtotal_minor=excluded.subtotal_minor, total_minor=excluded.total_minor, tax_minor=excluded.tax_minor, tax_lines=excluded.tax_lines, payment=excluded.payment, paid_at=COALESCE(excluded.paid_at, orders.paid_at), customer_name=excluded.customer_name, customer_phone=excluded.customer_phone, pos_payment_method=excluded.pos_payment_method, delivery_id=excluded.delivery_id, business=excluded.business, user=excluded.user, updated_at=excluded.updated_at, hlc=excluded.hlc, sync_failed_at=NULL, sync_error=NULL
289
- WHERE excluded.hlc >= orders.hlc OR orders.hlc IS NULL;`, order.id, order.business_id, order.order_number, order.callout_number, order.status, order.order_type, order.is_pos ? 1 : 0, JSON.stringify(order.items), order.subtotal_minor, order.total_minor, order.tax_minor, JSON.stringify(order.tax_lines), JSON.stringify(order.payment), paidAt, order.customer_name, order.customer_phone, order.pos_payment_method, order.delivery_id, order.business ? JSON.stringify(order.business) : null, order.user ? JSON.stringify(order.user) : null, order.inserted_at, updatedAt, hlc);
287
+ await db.runAsync(`INSERT INTO orders (id, business_id, order_number, callout_number, status, order_type, is_pos, items, subtotal_minor, total_minor, tax_minor, tax_lines, payment, paid_at, customer_name, customer_phone, pos_payment_method, payment_mode, delivery_id, business, user, inserted_at, updated_at, hlc)
288
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
289
+ ON CONFLICT(id) DO UPDATE SET business_id=excluded.business_id, order_number=excluded.order_number, callout_number=excluded.callout_number, status=excluded.status, order_type=excluded.order_type, is_pos=excluded.is_pos, items=excluded.items, subtotal_minor=excluded.subtotal_minor, total_minor=excluded.total_minor, tax_minor=excluded.tax_minor, tax_lines=excluded.tax_lines, payment=excluded.payment, paid_at=COALESCE(excluded.paid_at, orders.paid_at), customer_name=excluded.customer_name, customer_phone=excluded.customer_phone, pos_payment_method=excluded.pos_payment_method, payment_mode=excluded.payment_mode, delivery_id=excluded.delivery_id, business=excluded.business, user=excluded.user, updated_at=excluded.updated_at, hlc=excluded.hlc, sync_failed_at=NULL, sync_error=NULL
290
+ WHERE excluded.hlc >= orders.hlc OR orders.hlc IS NULL;`, order.id, order.business_id, order.order_number, order.callout_number, order.status, order.order_type, order.is_pos ? 1 : 0, JSON.stringify(order.items), order.subtotal_minor, order.total_minor, order.tax_minor, JSON.stringify(order.tax_lines), JSON.stringify(order.payment), paidAt, order.customer_name, order.customer_phone, order.pos_payment_method, order.payment_mode ?? null, order.delivery_id, order.business ? JSON.stringify(order.business) : null, order.user ? JSON.stringify(order.user) : null, order.inserted_at, updatedAt, hlc);
290
291
  notifyDbChange(order.business_id, "orders");
291
292
  }
292
293
  export async function getOrderById(db, businessId, idOrNumber) {
@@ -371,9 +372,9 @@ export async function upsertServerOrder(db, order, opts = {}) {
371
372
  order.inserted_at ??
372
373
  new Date().toISOString();
373
374
  const updatedAt = order.updated_at ?? order.inserted_at;
374
- await db.runAsync(`INSERT INTO orders (id, business_id, order_number, callout_number, status, order_type, is_pos, items, subtotal_minor, total_minor, tax_minor, tax_lines, payment, paid_at, customer_name, customer_phone, pos_payment_method, delivery_id, business, user, inserted_at, updated_at, hlc)
375
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
376
- ON CONFLICT(id) DO UPDATE SET business_id=excluded.business_id, order_number=excluded.order_number, callout_number=excluded.callout_number, status=excluded.status, order_type=excluded.order_type, is_pos=excluded.is_pos, items=excluded.items, subtotal_minor=excluded.subtotal_minor, total_minor=excluded.total_minor, tax_minor=excluded.tax_minor, tax_lines=excluded.tax_lines, payment=excluded.payment, paid_at=COALESCE(excluded.paid_at, orders.paid_at), customer_name=excluded.customer_name, customer_phone=excluded.customer_phone, pos_payment_method=excluded.pos_payment_method, delivery_id=excluded.delivery_id, business=excluded.business, user=excluded.user, updated_at=excluded.updated_at, hlc=excluded.hlc, sync_failed_at=NULL, sync_error=NULL;`, order.id, order.business_id, order.order_number, order.callout_number, status, order.order_type, order.is_pos ? 1 : 0, JSON.stringify(order.items ?? []), order.subtotal_minor ?? 0, order.total_minor ?? 0, order.tax_minor ?? 0, JSON.stringify(order.tax_lines ?? []), JSON.stringify(payment), paidAt, order.customer_name, order.customer_phone, order.pos_payment_method, order.delivery_id, order.business ? JSON.stringify(order.business) : null, order.user ? JSON.stringify(order.user) : null, order.inserted_at, updatedAt, hlc);
375
+ await db.runAsync(`INSERT INTO orders (id, business_id, order_number, callout_number, status, order_type, is_pos, items, subtotal_minor, total_minor, tax_minor, tax_lines, payment, paid_at, customer_name, customer_phone, pos_payment_method, payment_mode, delivery_id, business, user, inserted_at, updated_at, hlc)
376
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
377
+ ON CONFLICT(id) DO UPDATE SET business_id=excluded.business_id, order_number=excluded.order_number, callout_number=excluded.callout_number, status=excluded.status, order_type=excluded.order_type, is_pos=excluded.is_pos, items=excluded.items, subtotal_minor=excluded.subtotal_minor, total_minor=excluded.total_minor, tax_minor=excluded.tax_minor, tax_lines=excluded.tax_lines, payment=excluded.payment, paid_at=COALESCE(excluded.paid_at, orders.paid_at), customer_name=excluded.customer_name, customer_phone=excluded.customer_phone, pos_payment_method=excluded.pos_payment_method, payment_mode=excluded.payment_mode, delivery_id=excluded.delivery_id, business=excluded.business, user=excluded.user, updated_at=excluded.updated_at, hlc=excluded.hlc, sync_failed_at=NULL, sync_error=NULL;`, order.id, order.business_id, order.order_number, order.callout_number, status, order.order_type, order.is_pos ? 1 : 0, JSON.stringify(order.items ?? []), order.subtotal_minor ?? 0, order.total_minor ?? 0, order.tax_minor ?? 0, JSON.stringify(order.tax_lines ?? []), JSON.stringify(payment), paidAt, order.customer_name, order.customer_phone, order.pos_payment_method, order.payment_mode ?? (order.is_pos ? "prepaid" : "prepaid"), order.delivery_id, order.business ? JSON.stringify(order.business) : null, order.user ? JSON.stringify(order.user) : null, order.inserted_at, updatedAt, hlc);
377
378
  notifyDbChange(order.business_id, "orders");
378
379
  }
379
380
  /**
@@ -18,7 +18,7 @@ import { validateOrderCreateInput } from "../../validation/offline";
18
18
  import { buildItemSnapshots, snapshotsToOrderLines } from "./snapshots";
19
19
  import { getBusinessOrderFlowMode, initialPosOrderStatus } from "./order-flow";
20
20
  import { drainOne, pullRemoteOrders } from "../../sync/sync-jobs.service";
21
- import { getAuthToken } from "../../adapters/auth";
21
+ import { getAuthToken, useAuthStore } from "../../adapters/auth";
22
22
  import { canBusinessSync } from "../../sync/sync-guard";
23
23
  import { syncLogger } from "../../utils/sync-logger";
24
24
  import { getItem } from "../../adapters/kv";
@@ -130,6 +130,16 @@ export async function createLocalOrder(businessId, input) {
130
130
  const taxMinor = input.tax_minor ?? 0;
131
131
  const taxLines = input.tax_lines ?? [];
132
132
  const currencyCode = input.currency_code ?? "GHS";
133
+ const authUser = useAuthStore.getState().user;
134
+ const teller = authUser
135
+ ? {
136
+ id: authUser.id,
137
+ full_name: authUser.full_name ?? authUser.email ?? "Staff",
138
+ email: authUser.email ?? "",
139
+ phone: authUser.phone ?? "",
140
+ avatar_url: authUser.avatar_url ?? null,
141
+ }
142
+ : null;
133
143
  const order = {
134
144
  id,
135
145
  order_number: orderNumber,
@@ -161,7 +171,8 @@ export async function createLocalOrder(businessId, input) {
161
171
  delivery_lng: null,
162
172
  delivery_id: null,
163
173
  delivery_status: null,
164
- user: null,
174
+ user: teller,
175
+ teller,
165
176
  items: items,
166
177
  inserted_at: insertedAt,
167
178
  };
@@ -21,10 +21,16 @@ interface LocalOrderRow {
21
21
  status: string;
22
22
  total_minor: number;
23
23
  subtotal_minor?: number;
24
+ currency?: string;
24
25
  inserted_at: string;
26
+ is_pos?: boolean;
25
27
  payment_mode?: string;
26
28
  pos_payment_method?: string;
27
29
  order_type?: string;
30
+ user?: {
31
+ id?: string;
32
+ full_name?: string | null;
33
+ } | null;
28
34
  items?: Array<{
29
35
  quantity?: number;
30
36
  subtotal?: number;
@@ -52,6 +58,7 @@ interface AggregateOptions {
52
58
  export declare function filterByPeriod(orders: LocalOrderRow[], period: AnalyticsPeriod, now?: Date, range?: AnalyticsRange): LocalOrderRow[];
53
59
  export declare function aggregateLocal(orders: LocalOrderRow[], period: AnalyticsPeriod, opts: AggregateOptions, range?: AnalyticsRange): {
54
60
  period: AnalyticsPeriod;
61
+ currency: string;
55
62
  total_revenue_minor: number;
56
63
  total_orders: number;
57
64
  average_order_value_minor: number;
@@ -71,16 +78,33 @@ export declare function aggregateLocal(orders: LocalOrderRow[], period: Analytic
71
78
  count: number;
72
79
  date: string;
73
80
  }[];
81
+ by_channel: {
82
+ revenue_minor: number;
83
+ count: number;
84
+ channel: string;
85
+ percentage: number;
86
+ }[];
87
+ by_teller: {
88
+ user_id: string;
89
+ name: string;
90
+ revenue_minor: number;
91
+ count: number;
92
+ average_order_value_minor: number;
93
+ }[];
94
+ by_hour: {
95
+ hour: number;
96
+ revenue_minor: number;
97
+ count: number;
98
+ }[];
74
99
  offline: boolean;
75
100
  total_refunds_minor?: undefined;
76
101
  top_items?: undefined;
77
102
  orders_by_status?: undefined;
78
103
  revenue_by_day?: undefined;
79
104
  } | {
80
- by_payment_method?: undefined;
81
- by_order_type?: undefined;
82
105
  by_day?: undefined;
83
106
  period: AnalyticsPeriod;
107
+ currency: string;
84
108
  total_revenue_minor: number;
85
109
  total_orders: number;
86
110
  average_order_value_minor: number;
@@ -99,8 +123,45 @@ export declare function aggregateLocal(orders: LocalOrderRow[], period: Analytic
99
123
  date: string;
100
124
  revenue_minor: number;
101
125
  }[];
126
+ by_channel: {
127
+ revenue_minor: number;
128
+ count: number;
129
+ channel: string;
130
+ percentage: number;
131
+ }[];
132
+ by_payment_method: {
133
+ revenue_minor: number;
134
+ count: number;
135
+ method: string;
136
+ percentage: number;
137
+ }[];
138
+ by_order_type: {
139
+ revenue_minor: number;
140
+ count: number;
141
+ type: string;
142
+ }[];
143
+ by_teller: {
144
+ user_id: string;
145
+ name: string;
146
+ revenue_minor: number;
147
+ count: number;
148
+ average_order_value_minor: number;
149
+ }[];
150
+ by_hour: {
151
+ hour: number;
152
+ revenue_minor: number;
153
+ count: number;
154
+ }[];
102
155
  offline: boolean;
103
156
  };
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>;
157
+ interface AnalyticsPayload {
158
+ total_orders?: number;
159
+ [key: string]: unknown;
160
+ }
161
+ interface AnalyticsResponse {
162
+ data?: AnalyticsPayload;
163
+ [key: string]: unknown;
164
+ }
165
+ export declare function useSellerAnalyticsQuery(period?: AnalyticsPeriod, enabled?: boolean, range?: AnalyticsRange): import("@tanstack/react-query").UseQueryResult<AnalyticsResponse, Error>;
166
+ export declare function useRevenueSummaryQuery(period?: AnalyticsPeriod, enabled?: boolean, range?: AnalyticsRange): import("@tanstack/react-query").UseQueryResult<AnalyticsResponse, Error>;
106
167
  export {};
@@ -3,11 +3,13 @@
3
3
  * Offline-first: local SQLite aggregates serve instantly; the network
4
4
  * refreshes silently when online.
5
5
  */
6
- import { useQuery } from "@tanstack/react-query";
7
- import { useSyncExternalStore } from "react";
6
+ import { useQuery, useQueryClient } from "@tanstack/react-query";
7
+ import { useEffect, useSyncExternalStore } from "react";
8
8
  import { getActiveBusiness, subscribeBusiness } from "../adapters/business";
9
9
  import { analyticsService } from "../adapters/services";
10
10
  import { listLocalOrders } from "../domain/orders/service";
11
+ import { pullRemoteOrders } from "../sync/sync-jobs.service";
12
+ import { syncLogger } from "../utils/sync-logger";
11
13
  import { useIsEffectivelyOnline } from "./use-infinite-list";
12
14
  export const SELLER_ANALYTICS_KEYS = {
13
15
  all: ["seller-analytics"],
@@ -96,10 +98,25 @@ export function aggregateLocal(orders, period, opts, range) {
96
98
  const byMethod = new Map();
97
99
  const byType = new Map();
98
100
  const byDay = new Map();
101
+ const byChannel = new Map();
102
+ const byTeller = new Map();
103
+ const byHour = new Map();
99
104
  const itemQty = new Map();
100
105
  const itemRev = new Map();
101
106
  for (const o of revenueOrders) {
102
- const method = o.pos_payment_method ?? o.payment_mode ?? "cash";
107
+ // `is_pos` is the only online/local discriminator — same as backend.
108
+ const channel = o.is_pos ? "local" : "online";
109
+ const ch = byChannel.get(channel) ?? { revenue_minor: 0, count: 0 };
110
+ ch.revenue_minor += o.total_minor ?? 0;
111
+ ch.count += 1;
112
+ byChannel.set(channel, ch);
113
+ // POS uses the tendered method; online orders cannot truthfully be
114
+ // labelled card/MoMo from order data, so prepaid stays "online_prepaid".
115
+ const method = o.is_pos
116
+ ? (o.pos_payment_method ?? "cash")
117
+ : o.payment_mode === "pay_on_delivery"
118
+ ? "pay_on_delivery"
119
+ : "online_prepaid";
103
120
  const m = byMethod.get(method) ?? { revenue_minor: 0, count: 0 };
104
121
  m.revenue_minor += o.total_minor ?? 0;
105
122
  m.count += 1;
@@ -114,6 +131,25 @@ export function aggregateLocal(orders, period, opts, range) {
114
131
  d.revenue_minor += o.total_minor ?? 0;
115
132
  d.count += 1;
116
133
  byDay.set(day, d);
134
+ if (o.is_pos) {
135
+ const key = o.user?.id ?? "unassigned";
136
+ const teller = byTeller.get(key) ?? {
137
+ name: o.user?.full_name ?? "Unassigned",
138
+ revenue_minor: 0,
139
+ count: 0,
140
+ };
141
+ teller.revenue_minor += o.total_minor ?? 0;
142
+ teller.count += 1;
143
+ byTeller.set(key, teller);
144
+ }
145
+ const hourDate = new Date(o.inserted_at ?? "");
146
+ if (!Number.isNaN(hourDate.getTime())) {
147
+ const hour = String(hourDate.getUTCHours());
148
+ const h = byHour.get(hour) ?? { revenue_minor: 0, count: 0 };
149
+ h.revenue_minor += o.total_minor ?? 0;
150
+ h.count += 1;
151
+ byHour.set(hour, h);
152
+ }
117
153
  for (const it of o.items ?? []) {
118
154
  const name = it.menu_item?.name ?? "Unknown";
119
155
  const qty = it.quantity ?? 0;
@@ -131,30 +167,63 @@ export function aggregateLocal(orders, period, opts, range) {
131
167
  cancelled: filtered.filter((o) => o.status === "cancelled").length,
132
168
  pending: filtered.filter((o) => o.status === "pending").length,
133
169
  };
170
+ const by_channel = [...byChannel.entries()]
171
+ .map(([channel, v]) => ({
172
+ channel,
173
+ ...v,
174
+ percentage: pct(v.revenue_minor),
175
+ }))
176
+ .sort((a, b) => b.revenue_minor - a.revenue_minor);
177
+ const by_payment_method = [...byMethod.entries()]
178
+ .map(([method, v]) => ({
179
+ method,
180
+ ...v,
181
+ percentage: pct(v.revenue_minor),
182
+ }))
183
+ .sort((a, b) => b.revenue_minor - a.revenue_minor);
184
+ const by_order_type = [...byType.entries()]
185
+ .map(([type, v]) => ({ type, ...v }))
186
+ .sort((a, b) => b.revenue_minor - a.revenue_minor || a.type.localeCompare(b.type));
187
+ const by_teller = [...byTeller.entries()]
188
+ .map(([user_id, v]) => ({
189
+ user_id,
190
+ name: v.name,
191
+ revenue_minor: v.revenue_minor,
192
+ count: v.count,
193
+ average_order_value_minor: v.count
194
+ ? Math.floor(v.revenue_minor / v.count)
195
+ : 0,
196
+ }))
197
+ .sort((a, b) => b.revenue_minor - a.revenue_minor);
198
+ const by_hour = [...byHour.entries()]
199
+ .map(([hour, v]) => ({
200
+ hour: Number(hour),
201
+ revenue_minor: v.revenue_minor,
202
+ count: v.count,
203
+ }))
204
+ .sort((a, b) => a.hour - b.hour);
205
+ const currency = revenueOrders[0]?.currency ?? "GHS";
134
206
  if (opts.forRevenueSummary) {
135
207
  return {
136
208
  period,
209
+ currency,
137
210
  total_revenue_minor,
138
211
  total_orders,
139
212
  average_order_value_minor,
140
- by_payment_method: [...byMethod.entries()]
141
- .map(([method, v]) => ({
142
- method,
143
- ...v,
144
- percentage: pct(v.revenue_minor),
145
- }))
146
- .sort((a, b) => b.revenue_minor - a.revenue_minor),
147
- by_order_type: [...byType.entries()]
148
- .map(([type, v]) => ({ type, ...v }))
149
- .sort((a, b) => b.revenue_minor - a.revenue_minor),
213
+ by_payment_method,
214
+ by_order_type,
150
215
  by_day: [...byDay.entries()]
151
216
  .map(([date, v]) => ({ date, ...v }))
152
217
  .sort((a, b) => a.date.localeCompare(b.date)),
218
+ by_channel,
219
+ by_teller,
220
+ by_hour,
153
221
  offline: true,
154
222
  };
155
223
  }
156
224
  return {
157
225
  period,
226
+ currency,
158
227
  total_revenue_minor,
159
228
  total_orders,
160
229
  average_order_value_minor,
@@ -171,48 +240,130 @@ export function aggregateLocal(orders, period, opts, range) {
171
240
  revenue_by_day: [...byDay.entries()]
172
241
  .map(([date, v]) => ({ date, revenue_minor: v.revenue_minor }))
173
242
  .sort((a, b) => a.date.localeCompare(b.date)),
243
+ by_channel,
244
+ by_payment_method,
245
+ by_order_type,
246
+ by_teller,
247
+ by_hour,
174
248
  offline: true,
175
249
  };
176
250
  }
177
251
  async function localOrders(businessId) {
178
- const { data } = await listLocalOrders(businessId, {}, 1, 500);
179
- return data ?? [];
252
+ const pageSize = 500;
253
+ const first = await listLocalOrders(businessId, {}, 1, pageSize);
254
+ const rows = [...(first.data ?? [])];
255
+ const total = first.total ?? rows.length;
256
+ for (let page = 2; rows.length < total; page += 1) {
257
+ const next = await listLocalOrders(businessId, {}, page, pageSize);
258
+ const batch = next.data ?? [];
259
+ if (batch.length === 0)
260
+ break;
261
+ rows.push(...batch);
262
+ }
263
+ return rows;
180
264
  }
181
265
  function hasCustomRange(period, range) {
182
266
  return (period !== "custom" || Boolean(range?.date_from && range?.date_to));
183
267
  }
268
+ const ORDER_PULL_COOLDOWN_MS = 15000;
269
+ const lastOrderPullAt = new Map();
270
+ /**
271
+ * Refresh the local order read model in the background. Analytics renders
272
+ * from SQLite immediately; this pull is what makes a freshly opened screen
273
+ * converge with the server without blocking the first paint.
274
+ */
275
+ async function syncOrdersForAnalytics(businessId, queryClient) {
276
+ const now = Date.now();
277
+ const last = lastOrderPullAt.get(businessId) ?? 0;
278
+ if (now - last < ORDER_PULL_COOLDOWN_MS)
279
+ return;
280
+ lastOrderPullAt.set(businessId, now);
281
+ try {
282
+ await pullRemoteOrders(businessId);
283
+ await Promise.all([
284
+ queryClient.invalidateQueries({ queryKey: SELLER_ANALYTICS_KEYS.all }),
285
+ queryClient.invalidateQueries({ queryKey: REVENUE_KEYS.all }),
286
+ ]);
287
+ }
288
+ catch (error) {
289
+ syncLogger.debug("Analytics", "background order sync failed", {
290
+ businessId,
291
+ err: String(error),
292
+ });
293
+ }
294
+ }
295
+ /**
296
+ * Keep local data when it knows about more orders than the server snapshot
297
+ * (e.g. an offline order still in the outbox). Otherwise the server snapshot
298
+ * wins because it may include older history the device has never pulled.
299
+ */
300
+ function preferRemote(current, remote) {
301
+ const localOrders = current?.data?.total_orders ?? 0;
302
+ const remoteOrders = remote.data?.total_orders ?? 0;
303
+ return remoteOrders >= localOrders ? remote : (current ?? remote);
304
+ }
305
+ async function buildLocalAnalytics(businessId, period, range) {
306
+ try {
307
+ return {
308
+ data: aggregateLocal(await localOrders(businessId), period, { forRevenueSummary: false }, range),
309
+ };
310
+ }
311
+ catch {
312
+ return {
313
+ data: aggregateLocal([], period, { forRevenueSummary: false }, range),
314
+ };
315
+ }
316
+ }
317
+ async function buildLocalRevenue(businessId, period, range) {
318
+ try {
319
+ return {
320
+ data: aggregateLocal(await localOrders(businessId), period, { forRevenueSummary: true }, range),
321
+ };
322
+ }
323
+ catch {
324
+ return {
325
+ data: aggregateLocal([], period, { forRevenueSummary: true }, range),
326
+ };
327
+ }
328
+ }
184
329
  export function useSellerAnalyticsQuery(period = "today", enabled = true, range) {
185
330
  const businessId = useBusinessId();
186
331
  const online = useIsEffectivelyOnline();
332
+ const queryClient = useQueryClient();
187
333
  const customReady = hasCustomRange(period, range);
334
+ const queryKey = SELLER_ANALYTICS_KEYS.analytics(businessId ?? "", period, range);
335
+ const active = enabled && Boolean(businessId) && customReady;
336
+ // Local-first: read SQLite on every query run. The network is a background
337
+ // refresh only, so opening the screen never waits on the server.
338
+ useEffect(() => {
339
+ if (!active || !online || !businessId)
340
+ return;
341
+ void syncOrdersForAnalytics(businessId, queryClient);
342
+ }, [active, online, businessId, queryClient]);
188
343
  return useQuery({
189
- queryKey: SELLER_ANALYTICS_KEYS.analytics(businessId ?? "", period, range),
344
+ queryKey,
190
345
  queryFn: async () => {
191
- if (!online) {
192
- try {
193
- return {
194
- data: aggregateLocal(await localOrders(businessId), period, { forRevenueSummary: false }, range),
195
- };
196
- }
197
- catch {
198
- return {
199
- data: aggregateLocal([], period, { forRevenueSummary: false }, range),
200
- };
201
- }
202
- }
203
- try {
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);
208
- }
209
- catch {
210
- return {
211
- data: aggregateLocal(await localOrders(businessId), period, { forRevenueSummary: false }, range),
212
- };
346
+ const local = await buildLocalAnalytics(businessId, period, range);
347
+ if (online && businessId) {
348
+ // Do not await. Once the server snapshot arrives, merge it into the
349
+ // cache only when it is at least as complete as the local model.
350
+ void analyticsService
351
+ .fetchSellerAnalytics(businessId, period, range)
352
+ .then((remote) => {
353
+ const remoteResponse = remote;
354
+ const current = queryClient.getQueryData(queryKey);
355
+ queryClient.setQueryData(queryKey, preferRemote(current, remoteResponse));
356
+ })
357
+ .catch((error) => {
358
+ syncLogger.debug("Analytics", "background analytics refresh failed", {
359
+ businessId,
360
+ err: String(error),
361
+ });
362
+ });
213
363
  }
364
+ return local;
214
365
  },
215
- enabled: enabled && Boolean(businessId) && customReady,
366
+ enabled: active,
216
367
  staleTime: 30000,
217
368
  ...OFFLINE_FIRST,
218
369
  });
@@ -220,34 +371,34 @@ export function useSellerAnalyticsQuery(period = "today", enabled = true, range)
220
371
  export function useRevenueSummaryQuery(period = "today", enabled = true, range) {
221
372
  const businessId = useBusinessId();
222
373
  const online = useIsEffectivelyOnline();
374
+ const queryClient = useQueryClient();
223
375
  const customReady = hasCustomRange(period, range);
376
+ const queryKey = REVENUE_KEYS.summary(businessId ?? "", period, range);
377
+ const active = enabled && Boolean(businessId) && customReady;
378
+ useEffect(() => {
379
+ if (!active || !online || !businessId)
380
+ return;
381
+ void syncOrdersForAnalytics(businessId, queryClient);
382
+ }, [active, online, businessId, queryClient]);
224
383
  return useQuery({
225
- queryKey: REVENUE_KEYS.summary(businessId ?? "", period, range),
384
+ queryKey,
226
385
  queryFn: async () => {
227
- if (!online) {
228
- try {
229
- return {
230
- data: aggregateLocal(await localOrders(businessId), period, { forRevenueSummary: true }, range),
231
- };
232
- }
233
- catch {
234
- return {
235
- data: aggregateLocal([], period, { forRevenueSummary: true }, range),
236
- };
237
- }
238
- }
239
- try {
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);
243
- }
244
- catch {
245
- return {
246
- data: aggregateLocal(await localOrders(businessId), period, { forRevenueSummary: true }, range),
247
- };
386
+ const local = await buildLocalRevenue(businessId, period, range);
387
+ if (online && businessId) {
388
+ void analyticsService
389
+ .fetchRevenueSummary(businessId, period, range)
390
+ .then((remote) => {
391
+ const remoteResponse = remote;
392
+ const current = queryClient.getQueryData(queryKey);
393
+ queryClient.setQueryData(queryKey, preferRemote(current, remoteResponse));
394
+ })
395
+ .catch((error) => {
396
+ syncLogger.debug("Analytics", "background revenue refresh failed", { businessId, err: String(error) });
397
+ });
248
398
  }
399
+ return local;
249
400
  },
250
- enabled: enabled && Boolean(businessId) && customReady,
401
+ enabled: active,
251
402
  staleTime: 30000,
252
403
  ...OFFLINE_FIRST,
253
404
  });
@@ -21,6 +21,7 @@ import { buildItemSnapshots, snapshotsToOrderLines } from "../domain/orders/snap
21
21
  import { DEFAULT_ORDER_FLOW_MODE, getBusinessOrderFlowMode, initialPosOrderStatus, } from "../domain/orders/order-flow";
22
22
  import { generateLocalCalloutNumber, generateOrderNumberForBusiness, generateOrderNumber, } from "./order-number";
23
23
  import { getConnectivityState, subscribeConnectivity } from "../adapters/connectivity";
24
+ import { useAuthStore } from "../adapters/auth";
24
25
  import { canBusinessSync } from "./sync-guard";
25
26
  import { backoffDelayMs } from "./backoff";
26
27
  import { reportSyncFailure } from "../adapters/sync-failure";
@@ -121,6 +122,16 @@ function buildOptimisticOrder(command, payload, flowMode) {
121
122
  const now = new Date().toISOString();
122
123
  const method = payload.pos_payment_method ?? "cash";
123
124
  const isCash = method === "cash";
125
+ const authUser = useAuthStore.getState().user;
126
+ const teller = authUser
127
+ ? {
128
+ id: authUser.id,
129
+ full_name: authUser.full_name ?? authUser.email ?? "Staff",
130
+ email: authUser.email ?? "",
131
+ phone: authUser.phone ?? "",
132
+ avatar_url: authUser.avatar_url ?? null,
133
+ }
134
+ : null;
124
135
  const orderNumber = payload.order_number ?? generateOrderNumberForBusiness(command.business_id);
125
136
  return {
126
137
  id: command.entity_id ?? command.command_id,
@@ -162,7 +173,8 @@ function buildOptimisticOrder(command, payload, flowMode) {
162
173
  delivery_address: null,
163
174
  delivery_lat: null,
164
175
  delivery_lng: null,
165
- user: null,
176
+ user: teller,
177
+ teller,
166
178
  items: snapshotsToOrderLines(snapshots, command.command_id),
167
179
  inserted_at: now,
168
180
  };
@@ -189,7 +189,7 @@ export function createMemoryDriver() {
189
189
  }
190
190
  // orders insert (primary DB)
191
191
  if (s.includes("INSERT INTO orders")) {
192
- const [id, business_id, order_number, callout_number, status, order_type, is_pos, items, subtotal_minor, total_minor, tax_minor, tax_lines, payment, paid_at, customer_name, customer_phone, pos_payment_method, delivery_id, business, user, inserted_at, updated_at, hlc] = args;
192
+ const [id, business_id, order_number, callout_number, status, order_type, is_pos, items, subtotal_minor, total_minor, tax_minor, tax_lines, payment, paid_at, customer_name, customer_phone, pos_payment_method, payment_mode, delivery_id, business, user, inserted_at, updated_at, hlc] = args;
193
193
  const existing = tables.orders.find((r) => r.id === id);
194
194
  // LWW check via hlc (if existing and incoming older, skip) — only for
195
195
  // the local/LAN write path. The server write path (`upsertServerOrder`)
@@ -198,10 +198,10 @@ export function createMemoryDriver() {
198
198
  if (existing) {
199
199
  if (lwwGuarded && hlc && existing.hlc && hlc < existing.hlc)
200
200
  return { lastInsertRowId: 0, changes: 0 };
201
- Object.assign(existing, { business_id, order_number, callout_number, status, order_type, is_pos, items, subtotal_minor, total_minor, tax_minor, tax_lines, payment, paid_at: paid_at ?? existing.paid_at ?? null, customer_name, customer_phone, pos_payment_method, delivery_id, business, user, inserted_at, updated_at, hlc });
201
+ Object.assign(existing, { business_id, order_number, callout_number, status, order_type, is_pos, items, subtotal_minor, total_minor, tax_minor, tax_lines, payment, paid_at: paid_at ?? existing.paid_at ?? null, customer_name, customer_phone, pos_payment_method, payment_mode, delivery_id, business, user, inserted_at, updated_at, hlc });
202
202
  return { lastInsertRowId: 0, changes: 1 };
203
203
  }
204
- tables.orders.push({ id, business_id, order_number, callout_number, status, order_type, is_pos, items, subtotal_minor, total_minor, tax_minor, tax_lines, payment, paid_at: paid_at ?? null, customer_name, customer_phone, pos_payment_method, delivery_id, business, user, inserted_at, updated_at, hlc, deleted_at: null });
204
+ tables.orders.push({ id, business_id, order_number, callout_number, status, order_type, is_pos, items, subtotal_minor, total_minor, tax_minor, tax_lines, payment, paid_at: paid_at ?? null, customer_name, customer_phone, pos_payment_method, payment_mode, delivery_id, business, user, inserted_at, updated_at, hlc, deleted_at: null });
205
205
  return { lastInsertRowId: 1, changes: 1 };
206
206
  }
207
207
  // sync_jobs insert
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@teincfood/core",
3
- "version": "0.10.14",
3
+ "version": "0.10.16",
4
4
  "description": "TeincFood shared offline-first core — types, sync engine, local DB, reference data, and repositories for mobile + desktop",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",