Initial commit
This commit is contained in:
312
src/queries/hooks.ts
Normal file
312
src/queries/hooks.ts
Normal file
@@ -0,0 +1,312 @@
|
||||
/**
|
||||
* 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<UseQueryOptions>;
|
||||
|
||||
/** Reference data that only changes when someone changes it. */
|
||||
const stable = {
|
||||
staleTime: 5 * 60_000,
|
||||
refetchOnWindowFocus: false,
|
||||
} satisfies Partial<UseQueryOptions>;
|
||||
|
||||
/* ── 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<string, unknown>),
|
||||
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<string, unknown>),
|
||||
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<string, unknown>),
|
||||
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<string, unknown>),
|
||||
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,
|
||||
});
|
||||
}
|
||||
9
src/queries/interval.ts
Normal file
9
src/queries/interval.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* How often live data is re-read.
|
||||
*
|
||||
* It lives alone in this file rather than in `hooks.ts` because the derivation
|
||||
* layer needs it too — `posStatus.ts` sets its staleness threshold relative to
|
||||
* the poll interval — and pulling the whole React Query layer into a module of
|
||||
* pure functions to read one number is how those modules stop being testable.
|
||||
*/
|
||||
export const LIVE_REFETCH_MS = 30_000;
|
||||
77
src/queries/keys.ts
Normal file
77
src/queries/keys.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Query keys.
|
||||
*
|
||||
* Every Fiesta list endpoint requires a scoping id and 400s without one, so the
|
||||
* scoping id is part of the key by construction. That is not a caching nicety:
|
||||
* it is what stops one tenant's cached rows being served to another after a
|
||||
* switch.
|
||||
*/
|
||||
|
||||
export const queryKeys = {
|
||||
tenants: {
|
||||
all: ['tenants'] as const,
|
||||
list: () => [...queryKeys.tenants.all, 'list'] as const,
|
||||
locations: (tenantid: number) =>
|
||||
[...queryKeys.tenants.all, 'locations', tenantid] as const,
|
||||
search: (keyword: string) => [...queryKeys.tenants.all, 'search', keyword] as const,
|
||||
},
|
||||
|
||||
catalogue: {
|
||||
all: ['catalogue'] as const,
|
||||
products: (params: Record<string, unknown>) =>
|
||||
[...queryKeys.catalogue.all, 'products', params] as const,
|
||||
brands: () => [...queryKeys.catalogue.all, 'brands'] as const,
|
||||
importedRefs: (tenantid: number) =>
|
||||
[...queryKeys.catalogue.all, 'imported', tenantid] as const,
|
||||
},
|
||||
|
||||
products: {
|
||||
all: ['products'] as const,
|
||||
byLocation: (tenantid: number, locationid: number, page: number) =>
|
||||
[...queryKeys.products.all, 'location', tenantid, locationid, page] as const,
|
||||
categories: (tenantid: number) =>
|
||||
[...queryKeys.products.all, 'categories', tenantid] as const,
|
||||
subCategories: (tenantid: number, categoryid: number) =>
|
||||
[...queryKeys.products.all, 'subcategories', tenantid, categoryid] as const,
|
||||
count: (tenantid: number) => [...queryKeys.products.all, 'count', tenantid] as const,
|
||||
},
|
||||
|
||||
insights: {
|
||||
all: ['insights'] as const,
|
||||
orders: (tenantid: number) => [...queryKeys.insights.all, 'orders', tenantid] as const,
|
||||
locations: (tenantid: number) =>
|
||||
[...queryKeys.insights.all, 'locations', tenantid] as const,
|
||||
deliveries: (tenantid: number) =>
|
||||
[...queryKeys.insights.all, 'deliveries', tenantid] as const,
|
||||
posSales: (locationid: number) =>
|
||||
[...queryKeys.insights.all, 'pos-sales', locationid] as const,
|
||||
posHealth: (locationid: number) =>
|
||||
[...queryKeys.insights.all, 'pos-health', locationid] as const,
|
||||
posSalesSummary: (locationid: number, range: Record<string, unknown>) =>
|
||||
[...queryKeys.insights.all, 'pos-summary', locationid, range] as const,
|
||||
posBills: (locationid: number, params: Record<string, unknown>) =>
|
||||
[...queryKeys.insights.all, 'pos-bills', locationid, params] as const,
|
||||
orderList: (params: Record<string, unknown>) =>
|
||||
[...queryKeys.insights.all, 'order-list', params] as const,
|
||||
deliveryList: (params: Record<string, unknown>) =>
|
||||
[...queryKeys.insights.all, 'delivery-list', params] as const,
|
||||
},
|
||||
|
||||
people: {
|
||||
all: ['people'] as const,
|
||||
staff: (tenantid: number) => [...queryKeys.people.all, 'staff', tenantid] as const,
|
||||
posUsers: (tenantid: number, locationid: number) =>
|
||||
[...queryKeys.people.all, 'pos-users', tenantid, locationid] as const,
|
||||
posRoles: () => [...queryKeys.people.all, 'pos-roles'] as const,
|
||||
shifts: (tenantid: number, locationid: number) =>
|
||||
[...queryKeys.people.all, 'shifts', tenantid, locationid] as const,
|
||||
},
|
||||
|
||||
stock: {
|
||||
all: ['stock'] as const,
|
||||
requests: (params: Record<string, unknown>) =>
|
||||
[...queryKeys.stock.all, 'requests', params] as const,
|
||||
statement: (tenantid: number, locationid: number, params: Record<string, unknown>) =>
|
||||
[...queryKeys.stock.all, 'statement', tenantid, locationid, params] as const,
|
||||
},
|
||||
} as const;
|
||||
Reference in New Issue
Block a user