backend integration started

This commit is contained in:
2026-08-26 18:37:28 +05:30
parent 517a99d577
commit 3545819be8
50 changed files with 3461 additions and 3241 deletions

View File

@@ -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. */

View File

@@ -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 }),

View File

@@ -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,

View File

@@ -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 : [])),
};

View File

@@ -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 : [])),
};
/**

View File

@@ -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 },
),

View File

@@ -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,

View File

@@ -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`),
};

View File

@@ -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 {