/** * TanStack Query hooks. * * The 30-second cadence is the CONSOLE's refetch interval, not a terminal push * rate: the tills push over MQTT as bills happen, and this is how often the * screen goes back and asks. It is applied per-query rather than globally so * that a provisioning form is not re-polling a tenant list it will never see * change mid-edit. */ import { useQueries, useQuery, type UseQueryOptions } from '@tanstack/react-query'; import { catalogueApi, type CatalogueQuery } from '@/api/catalogue'; import { insightsApi, type DateRange, type OrderQuery } from '@/api/insights'; import { productsApi } from '@/api/products'; import { posUsersApi, staffApi } from '@/api/people'; import { stockApi, type StockRequestQuery } from '@/api/stock'; import { tenantsApi } from '@/api/tenants'; import { queryKeys } from './keys'; import { LIVE_REFETCH_MS } from './interval'; /** How often live-operations screens go back to the backend. */ export { LIVE_REFETCH_MS }; /** * Shared options for anything that changes during a trading day. * * `refetchIntervalInBackground: false` pauses polling while the tab is hidden * and resumes with an immediate refetch when it comes back — a console left * open on a second monitor should not spend the afternoon talking to the API. * `staleTime` sits below the interval so focus and mount refetches aren't * skipped as already-fresh. */ const live = { refetchInterval: LIVE_REFETCH_MS, refetchIntervalInBackground: false, refetchOnWindowFocus: true, refetchOnReconnect: true, staleTime: 15_000, } satisfies Partial; /** Reference data that only changes when someone changes it. */ const stable = { staleTime: 5 * 60_000, refetchOnWindowFocus: false, } satisfies Partial; /* ── Tenants ─────────────────────────────────────────────────────────────── */ export function useTenants() { return useQuery({ queryKey: queryKeys.tenants.list(), queryFn: () => tenantsApi.listAll(), ...stable, }); } export function useTenantLocations(tenantid: number | undefined) { return useQuery({ queryKey: queryKeys.tenants.locations(tenantid ?? 0), queryFn: () => tenantsApi.locations(tenantid as number), // Never fire without the scoping id — the backend would 400, and a 400 in // the cache reads to the user as a broken page rather than a missing input. enabled: typeof tenantid === 'number' && tenantid > 0, ...stable, }); } /* ── Global catalogue ────────────────────────────────────────────────────── */ export function useCatalogueProducts(query: CatalogueQuery) { return useQuery({ queryKey: queryKeys.catalogue.products(query as Record), queryFn: () => catalogueApi.products(query), ...stable, }); } export function useCatalogueBrands() { return useQuery({ queryKey: queryKeys.catalogue.brands(), queryFn: () => catalogueApi.brands(), ...stable, }); } export function useImportedRefs(tenantid: number | undefined) { return useQuery({ queryKey: queryKeys.catalogue.importedRefs(tenantid ?? 0), queryFn: () => catalogueApi.importedRefs(tenantid as number), enabled: typeof tenantid === 'number' && tenantid > 0, ...stable, }); } /* ── Products ────────────────────────────────────────────────────────────── */ export function useProductCategories(tenantid: number | undefined) { return useQuery({ queryKey: queryKeys.products.categories(tenantid ?? 0), queryFn: () => productsApi.categories(tenantid as number), enabled: typeof tenantid === 'number' && tenantid > 0, ...stable, }); } export function useLocationProducts( tenantid: number | undefined, locationid: number | undefined, page = 0, ) { return useQuery({ queryKey: queryKeys.products.byLocation(tenantid ?? 0, locationid ?? 0, page), queryFn: () => productsApi.locationProducts({ tenantid: tenantid as number, locationid: locationid as number, pageno: page, }), enabled: Boolean(tenantid) && Boolean(locationid), ...live, }); } /* ── Performance ─────────────────────────────────────────────────────────── */ export function useLocationSummary(tenantid: number | undefined) { return useQuery({ queryKey: queryKeys.insights.locations(tenantid ?? 0), queryFn: () => insightsApi.locationSummary(tenantid as number), enabled: typeof tenantid === 'number' && tenantid > 0, ...live, }); } export function useOrderSummary(tenantid: number | undefined) { return useQuery({ queryKey: queryKeys.insights.orders(tenantid ?? 0), queryFn: () => insightsApi.orderSummary(tenantid as number), enabled: typeof tenantid === 'number' && tenantid > 0, ...live, }); } /** * Till health for one outlet. * * One request per branch — `locationid` is required and singular on the POS * endpoints, so there is no tenant-wide call to reach for. */ export function usePosHealth(locationid: number | undefined) { return useQuery({ queryKey: queryKeys.insights.posHealth(locationid ?? 0), queryFn: () => insightsApi.posHealth(locationid as number), enabled: typeof locationid === 'number' && locationid > 0, ...live, }); } /* ── Store Admin: per-branch fan-out ─────────────────────────────────────── */ /** * Till health for several branches at once. * * `useQueries` rather than a loop of `useQuery`, because the number of branches * is data — a merchant can commission a fourth outlet while this screen is open, * and a hook count that changes between renders is a crash, not a re-render. * * One request per branch is not a choice: `/pos/health/location` takes a single * `location_id` and there is no tenant-wide POS endpoint anywhere in Fiesta. */ export function usePosHealthByBranch(locationIds: readonly number[]) { return useQueries({ queries: locationIds.map((locationid) => ({ queryKey: queryKeys.insights.posHealth(locationid), queryFn: () => insightsApi.posHealth(locationid), ...live, })), }); } /** Counter-sales totals for several branches at once. Same fan-out, same reason. */ export function usePosSalesByBranch(locationIds: readonly number[], range: DateRange = {}) { return useQueries({ queries: locationIds.map((locationid) => ({ queryKey: queryKeys.insights.posSalesSummary(locationid, { ...range }), queryFn: () => insightsApi.posSalesSummary(locationid, range), ...live, })), }); } /* ── Store Admin: stock ──────────────────────────────────────────────────── */ export function useStockRequests(query: StockRequestQuery | undefined) { return useQuery({ queryKey: queryKeys.stock.requests((query ?? {}) as Record), queryFn: () => stockApi.requests(query as StockRequestQuery), enabled: Boolean(query?.tenantid), ...live, }); } export function useStockStatement( tenantid: number | undefined, locationid: number | undefined, params: { keyword?: string; pagesize?: number } = {}, ) { return useQuery({ queryKey: queryKeys.stock.statement(tenantid ?? 0, locationid ?? 0, params), queryFn: () => stockApi.statement({ tenantid: tenantid as number, locationid: locationid as number, ...params, }), enabled: Boolean(tenantid) && Boolean(locationid), ...live, }); } /* ── Store Admin: order and bill rows ────────────────────────────────────── */ /** * The order rows. * * One call for every branch or for one, decided by whether `locationid` is * present — the backend routes on that, so there is nothing to fan out here. */ export function useOrders(query: OrderQuery | undefined) { return useQuery({ queryKey: queryKeys.insights.orderList((query ?? {}) as unknown as Record), queryFn: () => insightsApi.orders(query as OrderQuery), enabled: Boolean(query?.tenantid), ...live, }); } /** * Counter bills, per branch. * * Fanned out because `/pos/sales` takes a single `locationid` and there is no * tenant-wide POS read — the same constraint as the health and summary calls. */ export function usePosBillsByBranch( locationIds: readonly number[], params: DateRange & { pagesize?: number } = {}, ) { return useQueries({ queries: locationIds.map((locationid) => ({ queryKey: queryKeys.insights.posBills(locationid, { ...params }), queryFn: () => insightsApi.posSales(locationid, params), ...live, })), }); } /** The delivery jobs — its own read, its own row shape, its own status ladder. */ export function useDeliveries(query: OrderQuery | undefined) { return useQuery({ queryKey: queryKeys.insights.deliveryList((query ?? {}) as unknown as Record), queryFn: () => insightsApi.deliveries(query as OrderQuery), enabled: Boolean(query?.tenantid), ...live, }); } /* ── People ──────────────────────────────────────────────────────────────── */ /** The back-office directory. Role names arrive resolved; never map ids here. */ export function useStaff(tenantid: number | undefined) { return useQuery({ queryKey: queryKeys.people.staff(tenantid ?? 0), queryFn: () => staffApi.list(tenantid as number), enabled: Boolean(tenantid), ...stable, }); } /** * Till accounts, per branch. * * Fanned out because `getposusers` takes a single `locationid` — the same * constraint as every other POS read. */ export function usePosUsersByBranch(tenantid: number | undefined, locationIds: readonly number[]) { return useQueries({ queries: locationIds.map((locationid) => ({ queryKey: queryKeys.people.posUsers(tenantid ?? 0, locationid), queryFn: () => posUsersApi.list(tenantid as number, locationid), enabled: Boolean(tenantid), ...stable, })), }); } /** The role picker's source. Cached hard — it changes when the product changes. */ export function usePosRoles() { return useQuery({ queryKey: queryKeys.people.posRoles(), queryFn: () => posUsersApi.roles(), ...stable, }); } export function useStaffShifts(tenantid: number | undefined, locationid: number | undefined) { return useQuery({ queryKey: queryKeys.people.shifts(tenantid ?? 0, locationid ?? 0), queryFn: () => posUsersApi.shifts(tenantid as number, locationid as number), enabled: Boolean(tenantid) && Boolean(locationid), ...stable, }); }