110 lines
4.4 KiB
TypeScript
110 lines
4.4 KiB
TypeScript
/**
|
|
* The live stream.
|
|
*
|
|
* The console reads through TanStack Query on a 30-second poll. That is the
|
|
* floor, not the ceiling: a bill rung at 10:00:01 waits until 10:00:30 to
|
|
* appear. This connects to the backend's event stream and, when a till actually
|
|
* sells something, invalidates the queries that just went stale — so the screen
|
|
* updates in about a second instead of averaging fifteen.
|
|
*
|
|
* ── What it does NOT do ──────────────────────────────────────────────────────
|
|
*
|
|
* It never writes data into the cache. An event says "outlet 1185 sold
|
|
* something, products 42 and 77 were involved" and nothing more; the numbers
|
|
* still come from the API, because the API is the only thing that knows the
|
|
* total after the backend's locks, dedup and rejections have had their say.
|
|
* Painting figures straight from a broker message would put totals on screen
|
|
* that the database never agreed to.
|
|
*
|
|
* It also does not replace the poll. Every hook keeps its `refetchInterval`. If
|
|
* this connection drops, the broker is down, or MQTT_URL is unset on the
|
|
* server, the console behaves exactly as it did before — slower, never wrong.
|
|
* That is the whole reason this is safe to add.
|
|
*/
|
|
|
|
import { useEffect } from 'react';
|
|
import { useQueryClient } from '@tanstack/react-query';
|
|
import { API_BASE } from '@/api/client';
|
|
import { queryKeys } from './keys';
|
|
|
|
/** Mirrors `messaging.LiveEvent` on the backend. */
|
|
interface LiveEvent {
|
|
type: 'sale' | 'customer' | 'terminal';
|
|
locationid: number;
|
|
productids?: number[];
|
|
terminalid?: string;
|
|
at: string;
|
|
}
|
|
|
|
/**
|
|
* Watch one outlet.
|
|
*
|
|
* Pass `undefined` for either id and nothing connects — which is what the Store
|
|
* Admin's "All branches" view does, since a stream is per-outlet and opening
|
|
* one per branch would mean six connections for a screen that is already
|
|
* polling. Under All branches the poll remains the only refresh, deliberately.
|
|
*/
|
|
export function useLiveEvents(tenantid: number | undefined, locationid: number | undefined) {
|
|
const queryClient = useQueryClient();
|
|
|
|
useEffect(() => {
|
|
if (!tenantid || !locationid) return;
|
|
|
|
const url = `${API_BASE}/live/api/v1/web/live/events?tenantid=${tenantid}&locationid=${locationid}`;
|
|
const source = new EventSource(url);
|
|
|
|
/**
|
|
* A sale moved stock and added a bill.
|
|
*
|
|
* Invalidated by prefix rather than by exact key: the product list is keyed
|
|
* by page and the statement by its filters, and this has no idea which page
|
|
* or filter the operator is looking at. `invalidateQueries` with a prefix
|
|
* only refetches what is actually mounted, so the cost is the queries on
|
|
* screen — not every page ever visited.
|
|
*/
|
|
const onSale = () => {
|
|
void queryClient.invalidateQueries({ queryKey: queryKeys.stock.all });
|
|
void queryClient.invalidateQueries({ queryKey: queryKeys.products.all });
|
|
void queryClient.invalidateQueries({ queryKey: queryKeys.insights.posSales(locationid) });
|
|
void queryClient.invalidateQueries({
|
|
queryKey: [...queryKeys.insights.all, 'pos-bills', locationid],
|
|
});
|
|
void queryClient.invalidateQueries({
|
|
queryKey: [...queryKeys.insights.all, 'pos-summary', locationid],
|
|
});
|
|
};
|
|
|
|
/** A heartbeat — the presence board, and nothing else. */
|
|
const onTerminal = () => {
|
|
void queryClient.invalidateQueries({ queryKey: queryKeys.insights.posHealth(locationid) });
|
|
};
|
|
|
|
const onCustomer = () => {
|
|
void queryClient.invalidateQueries({ queryKey: queryKeys.customers.all });
|
|
};
|
|
|
|
source.addEventListener('sale', onSale);
|
|
source.addEventListener('terminal', onTerminal);
|
|
source.addEventListener('customer', onCustomer);
|
|
|
|
/**
|
|
* Errors are left alone on purpose.
|
|
*
|
|
* `EventSource` reconnects by itself, honouring the `retry:` the server
|
|
* sends, and it fires `error` on every one of those attempts. Logging here
|
|
* fills the console with noise during an ordinary backend deploy, and
|
|
* surfacing it to the operator would be reporting a failure that costs them
|
|
* nothing — the poll is still running underneath.
|
|
*/
|
|
|
|
return () => {
|
|
source.removeEventListener('sale', onSale);
|
|
source.removeEventListener('terminal', onTerminal);
|
|
source.removeEventListener('customer', onCustomer);
|
|
source.close();
|
|
};
|
|
}, [tenantid, locationid, queryClient]);
|
|
}
|
|
|
|
export type { LiveEvent };
|