backend integration started
This commit is contained in:
@@ -25,7 +25,7 @@ export const catalogueApi = {
|
||||
* merged, paginated list — the list is never gated behind a brand selector.
|
||||
*/
|
||||
products: (query: CatalogueQuery = {}) =>
|
||||
api.get<CatalogueProduct[]>(`${WEB}/catalogue/getproducts`, {
|
||||
api.list<CatalogueProduct>(`${WEB}/catalogue/getproducts`, {
|
||||
brand: query.brand,
|
||||
category: query.category,
|
||||
keyword: query.keyword,
|
||||
@@ -34,11 +34,11 @@ export const catalogueApi = {
|
||||
}),
|
||||
|
||||
/** Brands with product counts, for the filter chip row. Never hardcode this list. */
|
||||
brands: () => api.get<CatalogueBrand[]>(`${WEB}/catalogue/getbrands`),
|
||||
brands: () => api.list<CatalogueBrand>(`${WEB}/catalogue/getbrands`),
|
||||
|
||||
/** Requires a brand — the backend reads categories from one brand's table. */
|
||||
categories: (brand: string) =>
|
||||
api.get<string[]>(`${WEB}/catalogue/getcategories`, { brand }),
|
||||
api.list<string>(`${WEB}/catalogue/getcategories`, { brand }),
|
||||
|
||||
/**
|
||||
* One catalogue row in full — the fields the import leaves behind
|
||||
@@ -56,7 +56,7 @@ export const catalogueApi = {
|
||||
* mixes brands.
|
||||
*/
|
||||
importedRefs: (tenantid: number) =>
|
||||
api.get<CatalogueRef[]>(`${WEB}/products/getimportedcatalogueproducts`, { tenantid }),
|
||||
api.list<CatalogueRef>(`${WEB}/products/getimportedcatalogueproducts`, { tenantid }),
|
||||
};
|
||||
|
||||
/** Key for the imported-refs lookup. Both halves, always. */
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
* changes. Nothing else in the app calls `fetch`.
|
||||
*/
|
||||
|
||||
import { demoResolve, isDemoActive, MISS } from '@/demo';
|
||||
import type { FiestaEnvelope } from './types';
|
||||
|
||||
/**
|
||||
@@ -16,9 +15,34 @@ import type { FiestaEnvelope } from './types';
|
||||
*/
|
||||
const API_BASE = import.meta.env['VITE_API_BASE'] ?? '/fiesta';
|
||||
|
||||
/** Every console route lives under this prefix. `/mob/*` and `/pos/*` differ. */
|
||||
/** Every console route lives under this prefix. */
|
||||
export const WEB = '/live/api/v1/web';
|
||||
export const POS = '/live/api/v1/pos';
|
||||
|
||||
/**
|
||||
* The console's POS reads — counter sales and till presence.
|
||||
*
|
||||
* `/web/pos`, NOT `/pos`. Those are two different doors and the difference is
|
||||
* deliberate on the backend's side (`posroutes.go`): everything under `/v1/pos`
|
||||
* sits behind `middleware.PosAuth`, which verifies a TERMINAL's session token.
|
||||
* The console has no such token and cannot obtain one — `/pos/login` refuses an
|
||||
* account that is not a till account, which is the separation working as
|
||||
* intended.
|
||||
*
|
||||
* That guard currently waves unauthenticated requests through, so calling the
|
||||
* terminal group appeared to work. The routes file says what happens next in as
|
||||
* many words: "the moment `POS_AUTH_REQUIRED=true` is set, every POS screen in
|
||||
* the back office goes dark." The same five reads are registered again under
|
||||
* `/v1/web/pos` for exactly this caller, and that is where they belong.
|
||||
*/
|
||||
export const POS = '/live/api/v1/web/pos';
|
||||
|
||||
/**
|
||||
* The mobile surface, for the two endpoints the web group does not carry.
|
||||
*
|
||||
* Not a preference — `tenants/getstaffs` is registered on `/v1/mob/tenants`
|
||||
* only (`tenantroutes.go:35`), so the web path 404s.
|
||||
*/
|
||||
export const MOB = '/live/api/v1/mob';
|
||||
|
||||
/**
|
||||
* A failed call, carrying the backend's own message.
|
||||
@@ -73,17 +97,12 @@ interface RequestOptions {
|
||||
async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
||||
const { method = 'GET', params, body, signal } = options;
|
||||
|
||||
// Demo mode short-circuits before any network call. In a production build
|
||||
// `isDemoActive` is a constant `false`, so the bundler removes this branch
|
||||
// and the fixtures with it.
|
||||
if (import.meta.env.DEV && isDemoActive()) {
|
||||
const fixture = await demoResolve(path, params as Record<string, unknown> | undefined);
|
||||
if (fixture !== MISS) {
|
||||
// A beat of latency, so loading states are visible while working on them.
|
||||
await new Promise((resolve) => setTimeout(resolve, 180));
|
||||
return fixture as T;
|
||||
}
|
||||
}
|
||||
// There is exactly one path out of this function and it goes to `fetch`.
|
||||
//
|
||||
// A fixture short-circuit used to sit here, gated on a sessionStorage flag.
|
||||
// It is gone: every screen in every workspace now shows what the API
|
||||
// returned or an error, and there is no longer a mode in which the console
|
||||
// shows something else convincingly.
|
||||
|
||||
const url = `${API_BASE}${path}${toQueryString(params)}`;
|
||||
|
||||
@@ -128,7 +147,11 @@ async function request<T>(path: string, options: RequestOptions = {}): Promise<T
|
||||
);
|
||||
}
|
||||
|
||||
return envelope.details as T;
|
||||
// Most handlers put the payload in `details`, but a handful answer with
|
||||
// `data` instead — `products/getallproducts` and `products/create` among the
|
||||
// ones the console calls (`productController.go:400,206`). Reading only
|
||||
// `details` handed those two callers `undefined` with no error anywhere.
|
||||
return (envelope.details ?? envelope.data) as T;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -166,6 +189,26 @@ export const api = {
|
||||
get: <T>(path: string, params?: Record<string, QueryValue>, signal?: AbortSignal) =>
|
||||
request<T>(path, { method: 'GET', params, signal }),
|
||||
|
||||
/**
|
||||
* A read that returns rows.
|
||||
*
|
||||
* Fiesta answers an empty result with `details: null` about as often as with
|
||||
* `[]` — `Scan` into a nil slice marshals as null, and which one you get
|
||||
* depends on the handler rather than on anything meaningful. A page that maps
|
||||
* over the answer then dies on a white screen, and it dies for the most
|
||||
* ordinary case there is: a tenant with no branches yet, a shop with no
|
||||
* customers.
|
||||
*
|
||||
* So the coercion happens once, here, rather than as `?? []` on forty call
|
||||
* sites where the one that gets forgotten is the one that breaks. A non-array
|
||||
* answer is treated as empty rather than thrown, because the alternative is
|
||||
* an error screen for what is usually "nothing yet".
|
||||
*/
|
||||
list: <T>(path: string, params?: Record<string, QueryValue>, signal?: AbortSignal) =>
|
||||
request<T[] | null>(path, { method: 'GET', params, signal }).then((rows) =>
|
||||
Array.isArray(rows) ? rows : [],
|
||||
),
|
||||
|
||||
post: <T>(path: string, body?: unknown, params?: Record<string, QueryValue>) =>
|
||||
request<T>(path, { method: 'POST', body, params }),
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ export interface CustomerQuery {
|
||||
|
||||
export const customersApi = {
|
||||
list: (query: CustomerQuery) =>
|
||||
api.get<CustomerInfo[]>(`${WEB}/customers/gettenantcustomers`, {
|
||||
api.list<CustomerInfo>(`${WEB}/customers/gettenantcustomers`, {
|
||||
tenantid: query.tenantid,
|
||||
locationid: query.locationid,
|
||||
keyword: query.keyword || undefined,
|
||||
|
||||
@@ -15,6 +15,7 @@ import type {
|
||||
OrderRow,
|
||||
OrderSummary,
|
||||
PosLocationHealth,
|
||||
PosTerminalHealth,
|
||||
PosSalesPage,
|
||||
PosSalesSummary,
|
||||
} from './types';
|
||||
@@ -49,7 +50,7 @@ export const insightsApi = {
|
||||
* sending 0 silently gives page one rather than an error.
|
||||
*/
|
||||
orders: (query: OrderQuery) =>
|
||||
api.get<OrderRow[]>(`${WEB}/orders/tenant/getorders`, {
|
||||
api.list<OrderRow>(`${WEB}/orders/tenant/getorders`, {
|
||||
tenantid: query.tenantid,
|
||||
locationid: query.locationid,
|
||||
status: query.status,
|
||||
@@ -73,7 +74,7 @@ export const insightsApi = {
|
||||
* applocationid/userid/appuserid is present, so `tenantid` is required here.
|
||||
*/
|
||||
deliveries: (query: OrderQuery) =>
|
||||
api.get<DeliveryRow[]>(`${WEB}/deliveries/getdeliveries`, {
|
||||
api.list<DeliveryRow>(`${WEB}/deliveries/getdeliveries`, {
|
||||
tenantid: query.tenantid,
|
||||
locationid: query.locationid,
|
||||
status: query.status,
|
||||
@@ -89,7 +90,7 @@ export const insightsApi = {
|
||||
|
||||
/** Per-branch order totals for one tenant. `tenantid` is required. */
|
||||
locationSummary: (tenantid: number, range: DateRange = {}) =>
|
||||
api.get<LocationOrderSummary[]>(`${WEB}/orders/getlocationsummary`, { tenantid, ...range }),
|
||||
api.list<LocationOrderSummary>(`${WEB}/orders/getlocationsummary`, { tenantid, ...range }),
|
||||
|
||||
revenueSummary: (tenantid: number, range: DateRange = {}) =>
|
||||
api.get<OrderSummary>(`${WEB}/orders/getrevenuesummary`, { tenantid, ...range }),
|
||||
@@ -153,7 +154,17 @@ export const insightsApi = {
|
||||
posSalesSummary: (locationid: number, range: DateRange = {}) =>
|
||||
api.get<PosSalesSummary>(`${POS}/sales/summary`, { locationid, ...range }),
|
||||
|
||||
/** Till presence for one outlet — how many are online, how many bills are stranded. */
|
||||
posHealth: (locationid: number) =>
|
||||
api.get<PosLocationHealth>(`${POS}/health/location`, { location_id: locationid }),
|
||||
/**
|
||||
* Till presence for one outlet — how many are online, how many bills are stranded.
|
||||
*
|
||||
* Returns the terminal list, not the wrapper. The endpoint answers
|
||||
* `{location_id, total, online, terminals}`; every caller wants `terminals`,
|
||||
* and `summariseBranch` recomputes `online` from the heartbeats anyway
|
||||
* because Fiesta's figure counts a stale till as present. Unwrapping here
|
||||
* keeps that one shape fact in the API layer instead of on every page.
|
||||
*/
|
||||
posHealth: (locationid: number): Promise<PosTerminalHealth[]> =>
|
||||
api
|
||||
.get<PosLocationHealth>(`${POS}/health/location`, { location_id: locationid })
|
||||
.then((health) => (Array.isArray(health?.terminals) ? health.terminals : [])),
|
||||
};
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
* account ends up holding a role that matches nothing.
|
||||
*/
|
||||
|
||||
import { api, POS, WEB } from './client';
|
||||
import { api, MOB, WEB } from './client';
|
||||
import type { PosRole, PosUser, StaffInfo, StaffShift } from './types';
|
||||
|
||||
/* ── Back-office staff ───────────────────────────────────────────────────── */
|
||||
@@ -56,9 +56,16 @@ export const staffApi = {
|
||||
* and the backend says why that matters: "`app_roles` holds six rows for four
|
||||
* back-office roles and most accounts carry an id absent from it, so any
|
||||
* mapping written client-side is wrong."
|
||||
*
|
||||
* On the MOB prefix, and that is not a choice: `getstaffs` is registered on
|
||||
* `/v1/mob/tenants` only (`tenantroutes.go:35`) and has no `/web` twin, so the
|
||||
* path this used to call did not exist. The old console avoided the question
|
||||
* by using `users/getallusers`, whose SQL selects no `rolename` at all — it
|
||||
* had to map role ids client-side, which is the thing the backend warns
|
||||
* against above.
|
||||
*/
|
||||
list: (tenantid: number) =>
|
||||
api.get<StaffInfo[]>(`${WEB}/tenants/getstaffs`, { tenantid }),
|
||||
api.list<StaffInfo>(`${MOB}/tenants/getstaffs`, { tenantid }),
|
||||
|
||||
create: (body: CreateStaffRequest) => api.post<StaffInfo>(`${WEB}/users/create`, body),
|
||||
|
||||
@@ -106,9 +113,22 @@ export interface UpdatePosUserRequest {
|
||||
}
|
||||
|
||||
export const posUsersApi = {
|
||||
/** Till accounts at one outlet. `locationid` is required and singular. */
|
||||
/**
|
||||
* Till accounts at one outlet. `locationid` is required and singular.
|
||||
*
|
||||
* The envelope's `details` is an OBJECT — `{location_id, users}` — not the
|
||||
* array it reads like (`posController.go:858-860`). Asking for it as a list
|
||||
* returned an empty one every time, silently: the guard in `api.list` sees a
|
||||
* non-array and hands back `[]`, so the page showed "no till accounts" for a
|
||||
* shop that had them. Same shape trap as `/health/location`.
|
||||
*/
|
||||
list: (tenantid: number, locationid: number) =>
|
||||
api.get<PosUser[]>(`${POS}/getposusers`, { tenantid, locationid }),
|
||||
api
|
||||
.get<{ location_id?: number; users?: PosUser[] }>(`${WEB}/tenants/getposusers`, {
|
||||
tenantid,
|
||||
locationid,
|
||||
})
|
||||
.then((page) => (Array.isArray(page?.users) ? page.users : [])),
|
||||
|
||||
/**
|
||||
* The role picker's source.
|
||||
@@ -117,7 +137,7 @@ export const posUsersApi = {
|
||||
* endpoint also carries the label and the description a person needs to
|
||||
* choose between them — and a third role would appear here first.
|
||||
*/
|
||||
roles: () => api.get<PosRole[]>(`${POS}/posroles`),
|
||||
roles: () => api.list<PosRole>(`${WEB}/tenants/posroles`),
|
||||
|
||||
/**
|
||||
* Create a till account.
|
||||
@@ -126,13 +146,22 @@ export const posUsersApi = {
|
||||
* a listing never returns it: "An admin who loses it reissues rather than
|
||||
* looks it up." So it is shown at creation and never read back.
|
||||
*/
|
||||
create: (body: CreatePosUserRequest) => api.post<PosUser>(`${POS}/createposuser`, body),
|
||||
create: (body: CreatePosUserRequest) => api.post<PosUser>(`${WEB}/tenants/createposuser`, body),
|
||||
|
||||
update: (body: UpdatePosUserRequest) => api.put<PosUser>(`${POS}/updateposuser`, body),
|
||||
update: (body: UpdatePosUserRequest) => api.put<PosUser>(`${WEB}/tenants/updateposuser`, body),
|
||||
|
||||
/** Shift windows a till account can be put on. */
|
||||
/**
|
||||
* Shift windows a till account can be put on.
|
||||
*
|
||||
* Wrapped the same way — `{location_id, shifts}` (`posController.go:934-936`).
|
||||
*/
|
||||
shifts: (tenantid: number, locationid: number) =>
|
||||
api.get<StaffShift[]>(`${POS}/getstaffshifts`, { tenantid, locationid }),
|
||||
api
|
||||
.get<{ location_id?: number; shifts?: StaffShift[] }>(`${WEB}/tenants/getstaffshifts`, {
|
||||
tenantid,
|
||||
locationid,
|
||||
})
|
||||
.then((page) => (Array.isArray(page?.shifts) ? page.shifts : [])),
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -40,24 +40,37 @@ export interface LocationProductQuery {
|
||||
export const productsApi = {
|
||||
/** A store's own catalogue — what is actually imported, with live stock. */
|
||||
locationProducts: (query: LocationProductQuery) =>
|
||||
api.get<Product[]>(`${WEB}/products/getlocationproducts`, {
|
||||
api.list<Product>(`${WEB}/products/getlocationproducts`, {
|
||||
tenantid: query.tenantid,
|
||||
locationid: query.locationid,
|
||||
pageno: query.pageno ?? 0,
|
||||
pagesize: query.pagesize ?? 50,
|
||||
}),
|
||||
|
||||
/**
|
||||
* Every product a tenant owns, catalogue-imported or created.
|
||||
*
|
||||
* The payload is NOT a product list. It is `[]models.Tenantproducts` —
|
||||
* `{tenant, products}` groups, one per tenant (`models/product.go:246`) — and
|
||||
* it arrives under `data`, not `details`. Asked for as a flat list it handed
|
||||
* back one wrapper object whose keys are `tenant` and `products`, which the
|
||||
* SKU lookup in `importSheetProducts` then read as a product with no
|
||||
* `productid`: every sheet import resolved zero ids and wrote no locations
|
||||
* and no stock. Flattened here so no caller sees the grouping.
|
||||
*/
|
||||
allProducts: (tenantid: number) =>
|
||||
api.get<Product[]>(`${WEB}/products/getallproducts`, { tenantid }),
|
||||
api
|
||||
.list<{ products?: Product[] }>(`${WEB}/products/getallproducts`, { tenantid })
|
||||
.then((groups) => groups.flatMap((group) => group?.products ?? [])),
|
||||
|
||||
count: (tenantid: number) =>
|
||||
api.get<{ count?: number }>(`${WEB}/products/getproductscount`, { tenantid }),
|
||||
|
||||
categories: (tenantid: number) =>
|
||||
api.get<ProductCategory[]>(`${WEB}/products/getproductcategories`, { tenantid }),
|
||||
api.list<ProductCategory>(`${WEB}/products/getproductcategories`, { tenantid }),
|
||||
|
||||
subCategories: (tenantid: number, categoryid: number) =>
|
||||
api.get<ProductSubCategory[]>(`${WEB}/products/getproductsubcategories`, {
|
||||
api.list<ProductSubCategory>(`${WEB}/products/getproductsubcategories`, {
|
||||
tenantid,
|
||||
categoryid,
|
||||
}),
|
||||
@@ -111,7 +124,7 @@ export const productsApi = {
|
||||
* categoryids live in production, hardcoded to `moduleid = 2`, unscoped.
|
||||
*/
|
||||
tenantCategories: (tenantid: number) =>
|
||||
api.get<{ categoryid: number; categoryname: string }[]>(
|
||||
api.list<{ categoryid: number; categoryname: string }>(
|
||||
`${WEB}/products/gettenantcategories`,
|
||||
{ tenantid },
|
||||
),
|
||||
|
||||
@@ -70,7 +70,7 @@ export const stockApi = {
|
||||
}),
|
||||
|
||||
requests: (query: StockRequestQuery) =>
|
||||
api.get<StockRequest[]>(`${WEB}/products/getstockrequests`, {
|
||||
api.list<StockRequest>(`${WEB}/products/getstockrequests`, {
|
||||
tenantid: query.tenantid,
|
||||
locationid: query.locationid,
|
||||
status: query.status,
|
||||
@@ -115,7 +115,7 @@ export const stockApi = {
|
||||
pageno?: number;
|
||||
pagesize?: number;
|
||||
}) =>
|
||||
api.get<StockStatementRow[]>(`${WEB}/products/getstockstatement`, {
|
||||
api.list<StockStatementRow>(`${WEB}/products/getstockstatement`, {
|
||||
tenantid: params.tenantid,
|
||||
locationid: params.locationid,
|
||||
subcategoryid: params.subcategoryid,
|
||||
|
||||
@@ -62,7 +62,7 @@ export const tenantsApi = {
|
||||
* endpoint rather than a tenant-scoped one.
|
||||
*/
|
||||
listAll: (query: TenantListQuery = {}) =>
|
||||
api.get<TenantInfo[]>(`${WEB}/tenants/getalltenants`, {
|
||||
api.list<TenantInfo>(`${WEB}/tenants/getalltenants`, {
|
||||
pageno: query.pageno ?? 1,
|
||||
pagesize: query.pagesize ?? 100,
|
||||
status: query.status,
|
||||
@@ -83,14 +83,14 @@ export const tenantsApi = {
|
||||
* so this list is a queue to work from, not one to act on.
|
||||
*/
|
||||
byApproval: (status: 'pending' | 'Active' | 'InActive', keyword?: string) =>
|
||||
api.get<TenantInfo[]>(`${WEB}/tenants/search`, { status, keyword }),
|
||||
api.list<TenantInfo>(`${WEB}/tenants/search`, { status, keyword }),
|
||||
|
||||
/** Branches under one tenant. `tenantid` is required — omit it and it 400s. */
|
||||
locations: (tenantid: number) =>
|
||||
api.get<TenantLocation[]>(`${WEB}/tenants/gettenantlocations`, { tenantid }),
|
||||
api.list<TenantLocation>(`${WEB}/tenants/gettenantlocations`, { tenantid }),
|
||||
|
||||
search: (keyword: string) =>
|
||||
api.get<TenantInfo[]>(`${WEB}/tenants/searchbykeyword`, { keyword }),
|
||||
api.list<TenantInfo>(`${WEB}/tenants/searchbykeyword`, { keyword }),
|
||||
|
||||
/**
|
||||
* Provisions the enterprise, its first outlet, and the primary Administrator
|
||||
@@ -187,5 +187,5 @@ export const utilsApi = {
|
||||
* form and never called this, which means a category added to the master is
|
||||
* invisible to onboarding until someone edits the frontend.
|
||||
*/
|
||||
appCategories: () => api.get<AppCategory[]>(`${WEB}/utils/getappcategories`),
|
||||
appCategories: () => api.list<AppCategory>(`${WEB}/utils/getappcategories`),
|
||||
};
|
||||
|
||||
@@ -25,6 +25,12 @@ export interface FiestaEnvelope<T> {
|
||||
code: number;
|
||||
message?: string;
|
||||
details?: T;
|
||||
/**
|
||||
* The other payload key. Fiesta is not consistent about which one it uses:
|
||||
* `products/getallproducts` and `products/create` answer with `data`, most
|
||||
* everything else with `details`. The client reads `details ?? data`.
|
||||
*/
|
||||
data?: T;
|
||||
/** Present on the tenant login endpoints. */
|
||||
tenantform?: boolean;
|
||||
}
|
||||
@@ -469,8 +475,22 @@ export interface PosTerminalHealth {
|
||||
[key: string]: string | undefined;
|
||||
}
|
||||
|
||||
/** `GET /pos/health/location` returns the tills at one outlet, newest state first. */
|
||||
export type PosLocationHealth = PosTerminalHealth[];
|
||||
/**
|
||||
* `GET /pos/health/location` — the tills at one outlet, newest state first.
|
||||
*
|
||||
* The envelope's `details` is an **object**, not an array: the controller wraps
|
||||
* the terminal list in a count (`posController.go:298-307`). This was typed as a
|
||||
* bare array until a real sign-in blanked the Console page — the fixture
|
||||
* returned an array, so nothing caught it until the backend answered.
|
||||
* `insightsApi.posHealth` unwraps it, so callers still receive the list.
|
||||
*/
|
||||
export interface PosLocationHealth {
|
||||
location_id?: string | number;
|
||||
total?: number;
|
||||
/** Fiesta's own count of `status == "online"`. Recomputed in `posStatus.ts`. */
|
||||
online?: number;
|
||||
terminals?: PosTerminalHealth[];
|
||||
}
|
||||
|
||||
/** `GET /pos/sales/summary` — verified against `models/pos.go` `PosSalesSummary`. */
|
||||
export interface PosSalesSummary {
|
||||
|
||||
Reference in New Issue
Block a user