/** * 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 { deliveriesApi, ridersApi } from '@/api/deliveries'; 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, utilsApi, type TenantListQuery } from '@/api/tenants'; import { partnersApi } from '@/api/deliveries'; import { customersApi, type CustomerQuery } from '@/api/customers'; import { telemetryApi, type RiderSnapshot } from '@/api/telemetry'; import { uploadsApi } from '@/api/uploads'; import { APP_BROWSE_CATEGORY } from '@/features/catalogue/tenantCategories'; import { dedupePartners } from '@/features/nearle-admin/partnerList'; 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(query: TenantListQuery = {}) { return useQuery({ queryKey: queryKeys.tenants.list({ ...query }), queryFn: () => tenantsApi.listAll(query), ...stable, }); } /** * The approval queue. * * Its own hook rather than a filter over `useTenants`, because it is a * different endpoint: `getalltenants` cannot see an unapproved tenant at all. */ export function useTenantsByApproval(status: 'pending' | 'Active' | 'InActive', keyword?: string) { return useQuery({ queryKey: queryKeys.tenants.approval(status, keyword ?? ''), queryFn: () => tenantsApi.byApproval(status, keyword), ...stable, }); } /** The business-category master, for onboarding. */ export function useAppCategories() { return useQuery({ queryKey: queryKeys.tenants.appCategories(), queryFn: () => utilsApi.appCategories(), ...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, }); } /** * The categories inside one brand's table. * * Brand-scoped because the backend reads them from that brand's own table * (`catalogueController.go:38-45` refuses without one) — there is no * catalogue-wide category list to ask for. That is why the rail nests * categories under a brand rather than listing them at the top level. */ export function useCatalogueCategories(brand: string | undefined) { return useQuery({ queryKey: queryKeys.catalogue.categories(brand ?? ''), queryFn: () => catalogueApi.categories(brand as string), enabled: Boolean(brand), ...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 ────────────────────────────────────────────────────────────── */ /** * Every delivery partner across every region. * * `getpartners` takes one region at a time and refuses 0, so the whole list is * the regions fanned out and joined up. Three regions today — Coimbatore, * Madurai, Nagercoil — so this is three requests, not a page of them. * * Joined up, not concatenated: the backend scopes on `partnerlocations`, so a * partner covering two cities is returned by both of those requests and is * still one partner. See `dedupePartners`. */ export function useAllPartners() { const regions = useAppRegions(); const ids = (regions.data ?? []).map((region) => region.applocationid); const results = useQueries({ queries: ids.map((applocationid) => ({ queryKey: queryKeys.partners.inRegion(applocationid), queryFn: () => partnersApi.list(applocationid), ...stable, })), }); // Deduped, because the per-region answers legitimately overlap now that the // backend scopes on `partnerlocations` — see `dedupePartners`. return { data: dedupePartners(results.map((result) => result.data)), isLoading: regions.isLoading || results.some((result) => result.isLoading), }; } /** The delivery regions. `0` asks for all of them — see `utilsApi.appLocations`. */ export function useAppRegions() { return useQuery({ queryKey: queryKeys.regions.all, queryFn: () => utilsApi.appLocations(0), ...stable, }); } /** * One delivery partner's riders. * * The roster, not `getriders`: the second wants a clock-in stamped today, so a * rider added five minutes ago is absent from it — which reads as a failed * save. The directory shows everybody and reports duty as a state. */ export function usePartnerRiders(partnerid: number | undefined) { return useQuery({ queryKey: [...queryKeys.partners.all, 'riders', partnerid ?? 0] as const, queryFn: () => ridersApi.partnerRoster(partnerid as number), enabled: typeof partnerid === 'number' && partnerid > 0, ...stable, }); } /** * How many riders each partner has, keyed by partnerid. * * Fanned out because `getriderroster` takes one partner at a time. Five * partners today, so five requests — and the count is what makes the Riders * button worth pressing, since "Riders" alone asks you to open a drawer to find * out whether there are any. */ export function usePartnerRiderCounts(partnerids: readonly number[]) { const ids = [...new Set(partnerids)].filter((id) => id > 0); const results = useQueries({ queries: ids.map((partnerid) => ({ queryKey: [...queryKeys.partners.all, 'riders', partnerid] as const, queryFn: () => ridersApi.partnerRoster(partnerid), ...stable, })), }); const counts = new Map(); results.forEach((result, index) => { const id = ids[index]; if (id !== undefined && result.data) counts.set(id, result.data.length); }); return counts; } /** * Every GPS ping a partner's riders sent over a window. * * ── Why the window is small by default ──────────────────────────────────── * * The response is one row per ping and the riders ping constantly: August 2026 * returned 320,132 rows for eight riders. A month is several megabytes of JSON * to fetch, parse and smooth, so the fleet view asks for a day or two and says * which day it is showing. * * Cached longer than `stable` allows because it is history: yesterday's pings * do not change, and a refetch on every focus would re-download the day. */ export function usePartnerRiderLogs( partnerid: number | undefined, range: { fromdate: string; todate: string }, ) { return useQuery({ queryKey: queryKeys.partners.logs(partnerid ?? 0, range.fromdate, range.todate), queryFn: () => partnersApi.riderLogs({ partnerid: partnerid as number, fromdate: range.fromdate, todate: range.todate, }), enabled: typeof partnerid === 'number' && partnerid > 0 && Boolean(range.fromdate && range.todate), ...stable, staleTime: 5 * 60_000, }); } /** The regions one partner covers. */ export function usePartnerLocations(partnerid: number | undefined) { return useQuery({ queryKey: queryKeys.partners.locations(partnerid ?? 0), queryFn: () => partnersApi.locations(partnerid as number), enabled: typeof partnerid === 'number' && partnerid > 0, ...stable, }); } /** * The ten aisles the customer app groups products into. * * `productsubcategories` for category 2 — platform rows, the same ten for * every tenant, so the tenantid only satisfies the endpoint's signature. This * is the list `getproductsbysubcategory` buckets against, which makes it the * one the console has to name products from too; see `appAisle.ts`. */ export function useAppAisles(tenantid: number | undefined) { return useQuery({ queryKey: queryKeys.products.subCategories(tenantid ?? 0, APP_BROWSE_CATEGORY), queryFn: () => productsApi.subCategories(tenantid as number, APP_BROWSE_CATEGORY), enabled: typeof tenantid === 'number' && tenantid > 0, ...stable, }); } /** * One global-catalogue row, for the product drawer. * * Not retried: the common failure is that a re-scrape retired the source row, * and asking three more times does not bring it back. */ export function useCatalogueProduct(brand: string | undefined, sku: string | undefined) { return useQuery({ queryKey: queryKeys.catalogue.product(brand ?? '', sku ?? ''), queryFn: () => catalogueApi.product(brand as string, sku as string), enabled: Boolean(brand) && Boolean(sku), retry: false, ...stable, }); } /** * A tenant's products, at one branch or across all of them. * * `locationid` of 0 or undefined means EVERY branch, and the backend answers it * — one row per product, stock summed across outlets. It did not always: the * read required a single branch, so "All branches" listed the FIRST one and a * merchant with five outlets saw four products at RS Puram instead of the six * they stock. Picking one branch showed more than picking all of them. * * So the tenant alone is enough to run this query now. Gating on `locationid` * is what turned a missing backend feature into a silently wrong list. */ export function useLocationProducts( tenantid: number | undefined, locationid: number | undefined, page = 0, options: { /** * Treat a missing branch as "every branch" rather than as "not ready yet". * * Off by default, and that default is the safe one. A Store user is pinned * to a single outlet and their branch resolves asynchronously — so during * that window an opt-out default would answer tenant-wide and show them * every other branch's stock as if it were their own. * * The admin catalogue opts in, because there "All branches" is a real * selection a merchant made, not a value that has not arrived. */ allBranches?: boolean; } = {}, ) { const wantsAll = options.allBranches === true; return useQuery({ queryKey: queryKeys.products.byLocation(tenantid ?? 0, locationid ?? 0, page), queryFn: () => productsApi.locationProducts({ tenantid: tenantid as number, // 0 is meaningful to the backend here, not a missing value. locationid: locationid ?? 0, pageno: page, }), enabled: Boolean(tenantid) && (wantsAll || Boolean(locationid)), ...live, }); } /* ── Performance ─────────────────────────────────────────────────────────── */ /* No `useLocationSummary`. It had four callers — Reports, both Consoles and the platform's store detail — and every one of them read `revenue` and `totalorders` off its rows. Neither field is in the response; it carries counts under different names and no money at all. So every "App revenue" on this console read ₹0 against shops with real takings, and nothing went red because both fields were optional on a type with an index signature. It also ignores `fromdate`/`todate`, so even the counts it does return are all-time whatever the date picker says. Per-branch figures now come from the order rows via `branchOrderStats`, which has both the money and a working date filter. `insightsApi.locationSummary` stays in the API map, correctly typed, if the counts are ever wanted. */ 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, }); } /* ── 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, }); } /** * The stock statement for EVERY branch in scope, one request each. * * `getstockstatement` takes a single `locationid` and there is no tenant-wide * stock read, so a multi-branch view fans out — the same constraint, and the same * shape of answer, as `usePosSalesByBranch`. * * The Reports inventory tab read `scoped[0]` before this: whatever branch * happened to sort first stood in for the whole tenant, on a page where every * other figure covers all of them. A reader comparing inventory value against * this period's revenue was comparing one shop against the chain. */ export function useStockStatementByBranch( tenantid: number | undefined, locationIds: readonly number[], params: { keyword?: string; pagesize?: number } = {}, ) { return useQueries({ queries: locationIds.map((locationid) => ({ queryKey: queryKeys.stock.statement(tenantid ?? 0, locationid, params), queryFn: () => stockApi.statement({ tenantid: tenantid as number, locationid, ...params }), enabled: Boolean(tenantid), ...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), /* Either scope runs it. `tenantid: 0` is what a partner-scoped read sends for the unused field (see `scopeQuery`), and gating on the tenant alone meant the platform board fetched nothing at all — the map came up empty against a partner whose API rows numbered 376. */ enabled: Boolean(query?.tenantid || query?.partnerid), ...live, }); } /** * Every order in the window, paged until the rows run out. * * For the screens that TOTAL orders rather than list them. `useOrders` returns * one page and is right for a table with a pager under it; a sum built from one * page is simply wrong past that page, and wrong without saying so. * * Returns the flag as well as the rows: at the page bound the figures ARE partial * and the screen has to say so. A caller that ignores `truncated` has recreated * the bug this exists to fix. */ export function useAllOrders(query: OrderQuery | undefined, pagesize = 500) { return useQuery({ queryKey: queryKeys.insights.orderList({ ...((query ?? {}) as unknown as Record), all: true, pagesize, }), queryFn: () => insightsApi.ordersAll(query as OrderQuery, { pagesize }), enabled: Boolean(query?.tenantid || query?.partnerid), ...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), /* Either scope runs it. `tenantid: 0` is what a partner-scoped read sends for the unused field (see `scopeQuery`), and gating on the tenant alone meant the platform board fetched nothing at all — the map came up empty against a partner whose API rows numbered 376. */ enabled: Boolean(query?.tenantid || query?.partnerid), ...live, }); } /** * Riders on duty in one delivery region. * * `live` rather than `stable`, unlike the other reference lists: this one is * not reference data. A rider clocking on or off changes it during the shift, * and a picker offering somebody who went home twenty minutes ago sends the * order nowhere. * * Scoped by `applocationid`. Passing a tenant instead returns an empty list * with a 200 — see `deliveriesApi.riders`. */ export function useRiders(scope: { /** The merchant's own riders. */ tenantid?: number | undefined; /** A delivery partner's riders. */ partnerid?: number | undefined; /** Only used when neither of the above is given. */ applocationid?: number | undefined; }) { const { tenantid, partnerid, applocationid } = scope; return useQuery({ // The scope is part of the key, or one fleet's riders would be served to // the other after a toggle. queryKey: [...queryKeys.insights.all, 'riders', tenantid ?? 0, partnerid ?? 0, applocationid ?? 0] as const, queryFn: () => deliveriesApi.riders({ tenantid, partnerid, applocationid }), enabled: Boolean(tenantid || partnerid || applocationid), ...live, }); } /* ── People ──────────────────────────────────────────────────────────────── */ /** The back-office directory. Role names arrive resolved; never map ids here. */ /** * The branch's customers. * * Page size is always sent — see `customersApi`. Left off, the backend returns * an empty list rather than an error, which reads as "no customers". */ export function useCustomers(query: CustomerQuery | undefined) { return useQuery({ queryKey: queryKeys.customers.list({ ...query }), queryFn: () => customersApi.list(query as CustomerQuery), enabled: Boolean(query?.tenantid), ...live, }); } 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. */ /** * Till accounts, per branch. * * `includeInactive` is opt-in and part of the cache key, so a page that wants * to keep a switched-off cashier on screen gets them without changing what * every other caller sees — see `posUsersApi.list` for why the default stayed * as it was. */ export function usePosUsersByBranch( tenantid: number | undefined, locationIds: readonly number[], includeInactive = false, ) { return useQueries({ queries: locationIds.map((locationid) => ({ queryKey: [...queryKeys.people.posUsers(tenantid ?? 0, locationid), includeInactive] as const, queryFn: () => posUsersApi.list(tenantid as number, locationid, includeInactive), 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, }); } /** * The signed-in merchant's own business record. * * `gettenantinfo`, not `listAll`. The latter is `getalltenants` — paginated * across 262 merchants — so a shop on page two was simply not found, and a * profile screen built on it would show nothing with no visible reason why. */ export function useOwnTenant(tenantid: number | undefined) { return useQuery({ queryKey: queryKeys.tenants.byId(tenantid ?? 0), queryFn: () => tenantsApi.byId(tenantid as number), enabled: Boolean(tenantid), ...stable, }); } /** Upload receipts for a shop — used by the Uploads page and the setup checklist. */ export function useUploads(tenantid: number | undefined, locationid?: number) { return useQuery({ queryKey: queryKeys.uploads.list(tenantid ?? 0, locationid ?? 0), queryFn: () => uploadsApi.list({ tenantid, ...(locationid ? { locationid } : {}) }), enabled: Boolean(tenantid), ...stable, }); } /** * The rider directory — everyone, whether or not they are working today. * * `stable`, not `live`, and that is the difference from `useRiders`. This is a * staff list: it changes when somebody is hired or edited, not through the day * as shifts start and end. The on-duty read is the one that has to keep asking. */ export function useRiderRoster(tenantid: number | undefined) { return useQuery({ queryKey: queryKeys.insights.riderRoster(tenantid ?? 0), queryFn: () => ridersApi.roster(tenantid as number), enabled: Boolean(tenantid), ...stable, }); } /** Shifts a rider can be put on. Scoped by region; the param is required. */ export function useRiderShifts(applocationid: number | undefined) { return useQuery({ queryKey: queryKeys.insights.riderShifts(applocationid ?? 0), queryFn: () => ridersApi.shifts(applocationid as number), enabled: Boolean(applocationid), ...stable, }); } /** Delivery partners in a region. */ export function usePartners(applocationid: number | undefined) { return useQuery({ queryKey: queryKeys.insights.partners(applocationid ?? 0), queryFn: () => ridersApi.partners(applocationid as number), enabled: Boolean(applocationid), ...stable, }); } /** * Live positions for a set of riders. * * ── Fanned out, because there is no fleet form ──────────────────────────── * * `getriderperiodiclogs` answers for one rider at a time. A merchant's round is * a handful of riders, so a handful of requests every fifteen seconds is * cheap — and the fan-out means one rider's failure does not blank the others, * which a single fleet call would. * * ── Fifteen seconds, matching the source ────────────────────────────────── * * The rider app reports every twenty seconds or so, so polling faster only * re-reads the same fix. Fifteen keeps the board a beat ahead of the data * without asking for anything that does not exist yet. * * Paused when the tab is hidden: nobody is watching a map they cannot see, and * a background tab polling a third-party service all afternoon is rude. */ export function useRiderLive(userids: readonly number[], isEnabled = true) { const ids = [...new Set(userids)].filter((id) => id > 0); const results = useQueries({ queries: ids.map((userid) => ({ queryKey: queryKeys.live.rider(userid), queryFn: ({ signal }: { signal: AbortSignal }) => telemetryApi.rider(userid, signal), enabled: isEnabled, refetchInterval: isEnabled ? 15_000 : false, refetchIntervalInBackground: false, // A stale fix is worth drawing while the next one is fetched; blanking // the map every fifteen seconds would make it unreadable. staleTime: 10_000, retry: 1, })), }); const byRider = new Map(); results.forEach((result, index) => { const id = ids[index]; if (id !== undefined) byRider.set(id, result.data ?? null); }); return { data: byRider, isLoading: results.some((result) => result.isLoading), /** True while any rider is being re-read — for a quiet "updating" hint. */ isFetching: results.some((result) => result.isFetching), }; } /** * What is in one order — the line items, and the total Fiesta computed. * * Enabled only when a drawer is actually open on an order, because this is a * request per order and the list behind it can hold hundreds. A delivery row * carries the same `orderheaderid`, so the same hook serves both kinds of sheet. */ export function useOrderItems(orderheaderid: number | undefined) { return useQuery({ queryKey: queryKeys.insights.orderItems(orderheaderid ?? 0), queryFn: () => insightsApi.orderItems(orderheaderid as number), enabled: Boolean(orderheaderid), ...stable, }); }