Initial commit

This commit is contained in:
2026-08-24 20:35:18 +05:30
commit 1dc582ba07
85 changed files with 21081 additions and 0 deletions

53
src/api/catalogue.ts Normal file
View File

@@ -0,0 +1,53 @@
/**
* The global FMCG catalogue — a separate pgvector database, bridged to tenant
* data by the composite key `(brand, catalogueid)`.
*
* A catalogue row's bare `id` is only unique WITHIN its own brand table:
* `brand_dabur.id = 1` and `brand_nestle.id = 1` are different products. Every
* call that references a catalogue product sends both.
*/
import { api, WEB } from './client';
import type { CatalogueBrand, CatalogueProduct, CatalogueRef } from './types';
export interface CatalogueQuery {
/** Omit to search every brand merged — that is the "show everything" entry point. */
brand?: string;
category?: string;
keyword?: string;
pageno?: number;
pagesize?: number;
}
export const catalogueApi = {
/**
* Browse the global catalogue. Called with no `brand` this returns the full
* merged, paginated list — the list is never gated behind a brand selector.
*/
products: (query: CatalogueQuery = {}) =>
api.get<CatalogueProduct[]>(`${WEB}/catalogue/getproducts`, {
brand: query.brand,
category: query.category,
keyword: query.keyword,
pageno: query.pageno ?? 0,
pagesize: query.pagesize ?? 48,
}),
/** Brands with product counts, for the filter chip row. Never hardcode this list. */
brands: () => api.get<CatalogueBrand[]>(`${WEB}/catalogue/getbrands`),
categories: () => api.get<string[]>(`${WEB}/catalogue/getcategories`),
/**
* The `(brand, catalogueid)` pairs this tenant has already imported, for
* badging "Imported" in the browser. Called without `brand` because the list
* mixes brands.
*/
importedRefs: (tenantid: number) =>
api.get<CatalogueRef[]>(`${WEB}/products/getimportedcatalogueproducts`, { tenantid }),
};
/** Key for the imported-refs lookup. Both halves, always. */
export function catalogueKey(ref: CatalogueRef | CatalogueProduct): string {
return 'catalogueid' in ref ? `${ref.brand}:${ref.catalogueid}` : `${ref.brand}:${ref.id}`;
}

186
src/api/client.ts Normal file
View File

@@ -0,0 +1,186 @@
/**
* The Fiesta HTTP client.
*
* Everything the console knows about talking to the backend lives here, so the
* day the backend starts issuing a session token, this is the only file that
* changes. Nothing else in the app calls `fetch`.
*/
import { demoResolve, isDemoActive, MISS } from '@/demo';
import type { FiestaEnvelope } from './types';
/**
* In dev, Vite proxies `/fiesta` -> https://fiesta.nearle.app (see
* vite.config.ts), which keeps the network tab honest and sidesteps preflight
* surprises. In production the deployed host is set by VITE_API_BASE.
*/
const API_BASE = import.meta.env['VITE_API_BASE'] ?? '/fiesta';
/** Every console route lives under this prefix. `/mob/*` and `/pos/*` differ. */
export const WEB = '/live/api/v1/web';
export const POS = '/live/api/v1/pos';
/**
* A failed call, carrying the backend's own message.
*
* Fiesta answers HTTP 200 with `status: false` in several places, so the HTTP
* status alone is not enough to tell success from failure — both are checked.
*/
export class FiestaError extends Error {
readonly code: number;
readonly endpoint: string;
constructor(message: string, code: number, endpoint: string) {
super(message);
this.name = 'FiestaError';
this.code = code;
this.endpoint = endpoint;
}
/**
* True when the backend rejected the call for want of a scoping id.
*
* The IDOR pass added controller-level guards: an unscoped list call 400s
* rather than returning every tenant's rows. That is a bug in the caller,
* not a server fault, and it should surface as one.
*/
get isMissingScope(): boolean {
return this.code === 400 && /required/i.test(this.message);
}
}
export type QueryValue = string | number | boolean | null | undefined;
/** Drops empty params rather than sending `?tenantid=` and getting a 400 back. */
function toQueryString(params: Record<string, QueryValue> | undefined): string {
if (!params) return '';
const search = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (value === undefined || value === null || value === '') continue;
search.set(key, String(value));
}
const qs = search.toString();
return qs ? `?${qs}` : '';
}
interface RequestOptions {
method?: 'GET' | 'POST' | 'PUT' | 'DELETE';
params?: Record<string, QueryValue>;
body?: unknown;
signal?: AbortSignal;
}
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;
}
}
const url = `${API_BASE}${path}${toQueryString(params)}`;
const init: RequestInit = {
method,
headers: { Accept: 'application/json' },
signal: signal ?? null,
};
if (body !== undefined) {
init.headers = { ...init.headers, 'Content-Type': 'application/json' };
init.body = JSON.stringify(body);
}
let response: Response;
try {
response = await fetch(url, init);
} catch (cause) {
// A network failure and a 500 read very differently to a user; keep them
// distinguishable rather than collapsing both into "something went wrong".
throw new FiestaError(
cause instanceof DOMException && cause.name === 'AbortError'
? 'Request cancelled'
: 'Could not reach the server',
0,
path,
);
}
let envelope: FiestaEnvelope<T>;
try {
envelope = (await response.json()) as FiestaEnvelope<T>;
} catch {
throw new FiestaError(`Malformed response (HTTP ${response.status})`, response.status, path);
}
if (!response.ok || envelope.status === false) {
throw new FiestaError(
envelope.message ?? `Request failed (HTTP ${response.status})`,
envelope.code ?? response.status,
path,
);
}
return envelope.details as T;
}
/**
* The whole envelope, for the handful of callers that need `message` or
* `tenantform` on success — login being the one that matters.
*/
async function requestEnvelope<T>(
path: string,
options: RequestOptions = {},
): Promise<FiestaEnvelope<T>> {
const { method = 'GET', params, body } = options;
const url = `${API_BASE}${path}${toQueryString(params)}`;
const init: RequestInit = { method, headers: { Accept: 'application/json' } };
if (body !== undefined) {
init.headers = { ...init.headers, 'Content-Type': 'application/json' };
init.body = JSON.stringify(body);
}
let response: Response;
try {
response = await fetch(url, init);
} catch {
throw new FiestaError('Could not reach the server', 0, path);
}
try {
return (await response.json()) as FiestaEnvelope<T>;
} catch {
throw new FiestaError(`Malformed response (HTTP ${response.status})`, response.status, path);
}
}
export const api = {
get: <T>(path: string, params?: Record<string, QueryValue>, signal?: AbortSignal) =>
request<T>(path, { method: 'GET', params, signal }),
post: <T>(path: string, body?: unknown, params?: Record<string, QueryValue>) =>
request<T>(path, { method: 'POST', body, params }),
put: <T>(path: string, body?: unknown, params?: Record<string, QueryValue>) =>
request<T>(path, { method: 'PUT', body, params }),
del: <T>(path: string, body?: unknown, params?: Record<string, QueryValue>) =>
request<T>(path, { method: 'DELETE', body, params }),
envelope: requestEnvelope,
};
/** Normalises anything thrown into a message worth showing a person. */
export function errorMessage(error: unknown): string {
if (error instanceof FiestaError) return error.message;
if (error instanceof Error) return error.message;
return 'Something went wrong';
}

159
src/api/insights.ts Normal file
View File

@@ -0,0 +1,159 @@
/**
* Order, delivery and POS reads — the numbers behind store performance.
*
* Note the split: online orders come from `/web/orders/*` and counter sales
* from `/pos/*`, which is the ONLY part of the API carrying auth middleware.
* Any figure that blends the two is eventually consistent by construction, so
* screens that show one must also show its freshness.
*/
import { api, POS, WEB } from './client';
import type {
DeliveryRow,
DeliverySummary,
LocationOrderSummary,
OrderRow,
OrderSummary,
PosLocationHealth,
PosSalesPage,
PosSalesSummary,
} from './types';
export interface DateRange {
fromdate?: string;
todate?: string;
}
export interface OrderQuery extends DateRange {
tenantid: number;
/** Omit for every branch of the tenant. */
locationid?: number;
status?: string;
keyword?: string;
pageno?: number;
pagesize?: number;
}
export const insightsApi = {
/**
* The order rows themselves.
*
* `/orders/tenant/getorders` rather than the bare `/orders/getorders`: the
* controller routes on which ids are present, and passing a tenant with no
* partner, customer or app-user reaches `GetTenantOrders`. Passing a
* `locationid` as well reaches `GetTenantLocationOrders`, which is the
* branch-scoped read — so one call covers both "all branches" and "one
* branch" by presence alone.
*
* `pageno` is 1-based here. The controller floors anything <= 0 to 1, so
* sending 0 silently gives page one rather than an error.
*/
orders: (query: OrderQuery) =>
api.get<OrderRow[]>(`${WEB}/orders/tenant/getorders`, {
tenantid: query.tenantid,
locationid: query.locationid,
status: query.status,
keyword: query.keyword,
fromdate: query.fromdate,
todate: query.todate,
pageno: query.pageno ?? 1,
pagesize: query.pagesize ?? 50,
}),
/**
* The delivery jobs.
*
* A separate read from the orders list, NOT a filter over it. The rows are a
* different struct with different fields — rider name, planned vs actual
* distance, rider charge vs job value, notes — and a different status ladder.
* Deriving deliveries from orders, which is what this page did first, loses
* every one of those.
*
* The controller 400s unless one of tenantid/partnerid/customerid/
* applocationid/userid/appuserid is present, so `tenantid` is required here.
*/
deliveries: (query: OrderQuery) =>
api.get<DeliveryRow[]>(`${WEB}/deliveries/getdeliveries`, {
tenantid: query.tenantid,
locationid: query.locationid,
status: query.status,
keyword: query.keyword,
fromdate: query.fromdate,
todate: query.todate,
pageno: query.pageno ?? 1,
pagesize: query.pagesize ?? 50,
}),
orderSummary: (tenantid: number, range: DateRange = {}) =>
api.get<OrderSummary>(`${WEB}/orders/getordersummary`, { tenantid, ...range }),
/** Per-branch order totals for one tenant. `tenantid` is required. */
locationSummary: (tenantid: number, range: DateRange = {}) =>
api.get<LocationOrderSummary[]>(`${WEB}/orders/getlocationsummary`, { tenantid, ...range }),
revenueSummary: (tenantid: number, range: DateRange = {}) =>
api.get<OrderSummary>(`${WEB}/orders/getrevenuesummary`, { tenantid, ...range }),
timeSeries: (tenantid: number, range: DateRange = {}) =>
api.get<Record<string, unknown>[]>(`${WEB}/orders/gettimeseries`, { tenantid, ...range }),
deliverySummary: (tenantid: number, range: DateRange = {}) =>
api.get<DeliverySummary>(`${WEB}/deliveries/deliverysummary`, { tenantid, ...range }),
/**
* Counter sales for ONE outlet.
*
* `locationid` is required and singular — there is no tenant-wide POS call,
* so a multi-branch view fans out one request per branch.
*/
posSales: (
locationid: number,
params: DateRange & {
pageno?: number;
pagesize?: number;
/** The three server-side filters the old console's bills tab offers. */
terminalid?: string;
cashiername?: string;
paymentmode?: string;
} = {},
) =>
api.get<PosSalesPage>(`${POS}/sales`, {
locationid,
pageno: params.pageno ?? 0,
pagesize: params.pagesize ?? 50,
fromdate: params.fromdate,
todate: params.todate,
terminalid: params.terminalid,
cashiername: params.cashiername,
paymentmode: params.paymentmode,
}),
/**
* One counter bill in full, with its lines.
*
* `reference` accepts the till's order UUID, the invoice number, or this
* backend's posorderid — a support call starts from whichever the person is
* looking at, so the endpoint takes all three.
*/
posSaleDetail: (locationid: number, reference: string) =>
api.get<unknown>(`${POS}/sales/detail`, { locationid, reference }),
/**
* Counter-sales totals for ONE outlet.
*
* The richest read in the API: bill count, gross, tax, discount, roundoff and
* average bill, already broken down by payment mode, by day and by terminal.
* Reports gets its offline half from this and nothing else.
*
* Kept apart from the order summaries above on purpose. These are `posorders`
* rows; those are `orders` rows. The two are never added together — see
* `store-admin-backend-gap.md` §3.1 for why that would double-count a branch
* that both runs a till and uploads a spreadsheet.
*/
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 }),
};

160
src/api/people.ts Normal file
View File

@@ -0,0 +1,160 @@
/**
* People — back-office staff and till accounts.
*
* Two account systems that happen to share one table. A till account is NOT a
* Nearle Daily user: the backend excludes roles 7 and 8 from every application
* lookup inside the query itself, deliberately, so a cashier is "not found"
* rather than "refused". They are read and written through different endpoints
* with different conventions, and this file keeps them apart.
*
* Three things this module will not do, each for a reason recorded in
* `store-admin-user-menu-plan.md`:
*
* - **No delete.** `DELETE /users/delete` and `DELETE /deleteposuser` are hard
* deletes with no cascade. Deactivating via `status` is the safe equivalent
* and is what both list screens offer.
* - **No password management.** `PUT /users/update` doubles as the
* password-reset call and passwords are stored in clear. Creating an account
* is in scope; issuing its password is not, until hashing exists.
* - **Never send `roleid: -1`.** The old console clears a role that way;
* `UpdateStaff` does not special-case it, so `-1` lands in the column and the
* account ends up holding a role that matches nothing.
*/
import { api, POS, WEB } from './client';
import type { PosRole, PosUser, StaffInfo, StaffShift } from './types';
/* ── Back-office staff ───────────────────────────────────────────────────── */
export interface CreateStaffRequest {
tenantid: number;
locationid: number;
firstname: string;
lastname?: string;
email: string;
contactno: string;
roleid: number;
status?: string;
}
export interface UpdateStaffRequest {
userid: number;
firstname?: string;
lastname?: string;
email?: string;
contactno?: string;
roleid?: number;
locationid?: number;
status?: string;
}
export const staffApi = {
/**
* The tenant's back-office directory.
*
* `getstaffs`, NOT `getallusers`. This one resolves `rolename` server-side,
* 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."
*/
list: (tenantid: number) =>
api.get<StaffInfo[]>(`${WEB}/tenants/getstaffs`, { tenantid }),
create: (body: CreateStaffRequest) => api.post<StaffInfo>(`${WEB}/users/create`, body),
/**
* Update a person.
*
* GORM's `Updates` with a struct skips zero values, so an omitted field is
* left alone rather than blanked — which is why every field here is optional
* and why clearing something is not possible through this call.
*/
update: (body: UpdateStaffRequest) => api.put<StaffInfo>(`${WEB}/users/update`, body),
};
/* ── Till accounts ───────────────────────────────────────────────────────── */
export interface CreatePosUserRequest {
tenantid: number;
locationid: number;
full_name: string;
/**
* The role NAME, lowercase — "supervisor" or "cashier".
*
* Not the id. `PosRoleFromName` reads the name off the request and returns 0
* for anything it does not recognise, which every caller treats as a refusal
* rather than as a default. Sending 7 or 8 here does nothing.
*/
role: string;
/** Ten digits. The till matches on it exactly — see the note in `normaliseMobile`. */
contactno: string;
pin?: string;
/** A `staffshifts.staff_shift_id`. Zero leaves it unset. */
shift_id?: number;
status?: string;
}
export interface UpdatePosUserRequest {
tenantid: number;
locationid: number;
user_id: number;
full_name?: string;
role?: string;
contactno?: string;
shift_id?: number;
status?: string;
}
export const posUsersApi = {
/** Till accounts at one outlet. `locationid` is required and singular. */
list: (tenantid: number, locationid: number) =>
api.get<PosUser[]>(`${POS}/getposusers`, { tenantid, locationid }),
/**
* The role picker's source.
*
* Read rather than hardcoded. Supervisor is 7 and Cashier is 8 today, but the
* 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`),
/**
* Create a till account.
*
* The response carries the PIN or password ONCE. The backend is explicit that
* 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),
update: (body: UpdatePosUserRequest) => api.put<PosUser>(`${POS}/updateposuser`, body),
/** Shift windows a till account can be put on. */
shifts: (tenantid: number, locationid: number) =>
api.get<StaffShift[]>(`${POS}/getstaffshifts`, { tenantid, locationid }),
};
/**
* Ten digits, or nothing.
*
* The till matches the mobile number EXACTLY, so `+91 98765 43210` typed back
* as `9876543210` would not find the row. Stripping to the last ten digits at
* the edge means an admin can paste whatever their contact list gave them.
*/
export function normaliseMobile(input: string): string {
const digits = input.replace(/\D/g, '');
return digits.length > 10 ? digits.slice(-10) : digits;
}
/** Mon-first mask → "Mon–Fri", "Every day". `weekdays` empty means every day. */
export function weekdayLabel(mask: string | undefined): string {
if (!mask || !/^[01]{7}$/.test(mask)) return 'Every day';
const days = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
const on = [...mask].map((bit, index) => (bit === '1' ? days[index] : null)).filter(Boolean);
if (on.length === 7) return 'Every day';
if (on.length === 0) return '—';
if (mask === '1111100') return 'Mon–Fri';
if (mask === '0000011') return 'Weekends';
return on.join(', ');
}

213
src/api/products.ts Normal file
View File

@@ -0,0 +1,213 @@
/**
* Product, stock and import endpoints.
*
* Two import paths exist and they are NOT symmetric — the asymmetry is the
* backend's, not a choice made here:
*
* Catalogue path : one batch call, idempotent. Re-importing the same
* (tenantid, brand, catalogueid) tops up stock and
* overwrites price instead of duplicating.
*
* Sheet path : `create` accepts ONE product, not an array, and does not
* return the generated productid — the controller passes the
* struct to the service by value, so GORM writes the id into
* a copy that is then discarded, and the response echoes what
* was sent. So a sheet import is N creates, then a lookup by
* SKU to resolve ids, then one batched location call and one
* batched stock call.
*
* `importSheetProducts` below encapsulates that whole dance so no screen has to
* know about it.
*/
import { api, WEB } from './client';
import type {
ImportCatalogueProductRequest,
Product,
ProductCategory,
ProductLocationRequest,
ProductStockRequest,
ProductSubCategory,
} from './types';
export interface LocationProductQuery {
tenantid: number;
locationid: number;
pageno?: number;
pagesize?: number;
}
export const productsApi = {
/** A store's own catalogue — what is actually imported, with live stock. */
locationProducts: (query: LocationProductQuery) =>
api.get<Product[]>(`${WEB}/products/getlocationproducts`, {
tenantid: query.tenantid,
locationid: query.locationid,
pageno: query.pageno ?? 0,
pagesize: query.pagesize ?? 50,
}),
allProducts: (tenantid: number) =>
api.get<Product[]>(`${WEB}/products/getallproducts`, { tenantid }),
count: (tenantid: number) =>
api.get<{ count?: number }>(`${WEB}/products/getproductscount`, { tenantid }),
categories: (tenantid: number) =>
api.get<ProductCategory[]>(`${WEB}/products/getproductcategories`, { tenantid }),
subCategories: (tenantid: number, categoryid: number) =>
api.get<ProductSubCategory[]>(`${WEB}/products/getproductsubcategories`, {
tenantid,
categoryid,
}),
/** Batch, idempotent. Send the whole selection in one call. */
importFromCatalogue: (rows: ImportCatalogueProductRequest[]) =>
api.post<unknown>(`${WEB}/products/importcatalogueproduct`, rows),
/** Single product only — the backend parses one object, not an array. */
createProduct: (product: Partial<Product>) =>
api.post<Product>(`${WEB}/products/create`, product),
/** Array. Upserts on (tenantid, locationid, productid). */
createProductLocations: (rows: ProductLocationRequest[]) =>
api.post<unknown>(`${WEB}/products/createproductlocation`, rows),
/** Array. Appends to the stock ledger. */
createProductStock: (rows: ProductStockRequest[]) =>
api.post<unknown>(`${WEB}/products/createproductstock`, rows),
publish: (body: { tenantid: number; locationid: number; productid: number }) =>
api.post<unknown>(`${WEB}/products/publishproduct`, body),
/** Unlinks from the store. The product row and its order history survive. */
removeFromStore: (body: { tenantid: number; locationid: number; productid: number }) =>
api.del<unknown>(`${WEB}/products/deleteproductlocation`, body),
};
/* ────────────────────────────────────────────────────────────────────────────
The sheet-import dance
──────────────────────────────────────────────────────────────────────────── */
/** One validated row from the uploaded workbook. */
export interface SheetProductRow {
productname: string;
productsku: string;
categoryid: number;
subcategoryid: number;
retailprice: number;
productcost: number;
taxpercent: number;
quantity: number;
productunit?: string;
unitvalue?: string;
productbrand?: string;
productdesc?: string;
}
export interface SheetImportResult {
created: number;
linked: number;
stocked: number;
/** Rows the backend rejected, paired with the reason, so they can be retried. */
failures: { row: SheetProductRow; reason: string }[];
}
export interface SheetImportOptions {
tenantid: number;
locationid: number;
rows: SheetProductRow[];
onProgress?: (done: number, total: number) => void;
}
/**
* Imports a parsed sheet.
*
* NOT idempotent, and it cannot be made so from this side: nothing in the API
* dedupes on SKU, so uploading the same workbook twice creates the products
* twice. The importer UI is responsible for warning before a re-upload.
*
* Creates run sequentially rather than in parallel on purpose. There is no
* batch create, and firing 500 concurrent writes at a single-instance Go
* service to save a few seconds is a poor trade against a half-imported tenant.
*/
export async function importSheetProducts(
options: SheetImportOptions,
): Promise<SheetImportResult> {
const { tenantid, locationid, rows, onProgress } = options;
const failures: SheetImportResult['failures'] = [];
const createdSkus: string[] = [];
for (const [index, row] of rows.entries()) {
try {
await productsApi.createProduct({
tenantid,
productname: row.productname,
productsku: row.productsku,
categoryid: row.categoryid,
subcategoryid: row.subcategoryid,
retailprice: row.retailprice,
productcost: row.productcost,
taxpercent: row.taxpercent,
productunit: row.productunit,
unitvalue: row.unitvalue,
productbrand: row.productbrand,
productdesc: row.productdesc,
productstatus: 'Active',
});
createdSkus.push(row.productsku);
} catch (error) {
failures.push({ row, reason: error instanceof Error ? error.message : 'Create failed' });
}
onProgress?.(index + 1, rows.length);
}
if (createdSkus.length === 0) {
return { created: 0, linked: 0, stocked: 0, failures };
}
// Resolve the ids the create endpoint refused to hand back.
const all = await productsApi.allProducts(tenantid);
const bySku = new Map<string, Product>();
for (const product of all) {
if (product.productsku) bySku.set(product.productsku, product);
}
const locationRows: ProductLocationRequest[] = [];
const stockRows: ProductStockRequest[] = [];
for (const row of rows) {
if (!createdSkus.includes(row.productsku)) continue;
const product = bySku.get(row.productsku);
if (!product) {
failures.push({ row, reason: 'Created, but could not be found again by SKU' });
continue;
}
locationRows.push({
tenantid,
locationid,
productid: product.productid,
price: row.retailprice,
status: 'available',
});
stockRows.push({
tenantid,
locationid,
productid: product.productid,
quantity: row.quantity,
stocktype: 'in',
status: 'Active',
});
}
if (locationRows.length > 0) await productsApi.createProductLocations(locationRows);
if (stockRows.length > 0) await productsApi.createProductStock(stockRows);
return {
created: createdSkus.length,
linked: locationRows.length,
stocked: stockRows.length,
failures,
};
}

98
src/api/stock.ts Normal file
View File

@@ -0,0 +1,98 @@
/**
* Stock requests and stock movement — the Store Admin's approval surface.
*
* Read `store-admin-backend-gap.md` §2.2 before extending this file. The
* approval workflow the spec describes does not exist in Fiesta: the request
* table has no reason, requester, approved quantity, approver or remarks, and
* `UpdateStockRequest(requestID, status)` takes a bare string. Exactly one
* value does anything — "Received" — and it posts a stock movement for the FULL
* requested quantity, guarded against double-receiving.
*
* So "approve for a different quantity" is not implemented here because it
* cannot be implemented here. It is a backend change, not a frontend one.
*/
import { api, WEB } from './client';
import type { StockRequest, StockStatementRow } from './types';
/**
* The status values the backend actually distinguishes.
*
* `Received` is the only one with behaviour. The others are stored verbatim and
* read back, which is enough to drive a queue but is not a state machine — the
* backend will accept any string at all.
*/
export const STOCK_REQUEST_STATUS = {
pending: 'Pending',
received: 'Received',
rejected: 'Rejected',
} as const;
export type StockRequestStatus = (typeof STOCK_REQUEST_STATUS)[keyof typeof STOCK_REQUEST_STATUS];
export interface StockRequestQuery {
tenantid: number;
/** Omit for every branch. */
locationid?: number;
status?: string;
date?: string;
pageno?: number;
pagesize?: number;
}
export const stockApi = {
requests: (query: StockRequestQuery) =>
api.get<StockRequest[]>(`${WEB}/products/getstockrequests`, {
tenantid: query.tenantid,
locationid: query.locationid,
status: query.status,
date: query.date,
pageno: query.pageno ?? 0,
pagesize: query.pagesize ?? 50,
}),
/**
* Approve a request — which means marking it Received.
*
* This adds `request.qty` to the branch's stock. There is no way to approve a
* different amount: the service reads the quantity off the request row, not
* off this call.
*/
approve: (requestid: number) =>
api.put<unknown>(`${WEB}/products/updatestockrequest`, {
requestid,
status: STOCK_REQUEST_STATUS.received,
}),
/** Reject — a status write and nothing else. No stock moves, no reason stored. */
reject: (requestid: number) =>
api.put<unknown>(`${WEB}/products/updatestockrequest`, {
requestid,
status: STOCK_REQUEST_STATUS.rejected,
}),
/**
* Stock movement for one branch.
*
* Opening, credit, debit, closing — and that is the whole vocabulary.
* `productstocks.stocktype` is `in`/`out`, so a sale, a transfer and a
* correction are indistinguishable once written. The spec's seven movement
* types (§3.8) cannot be sourced; see gap §2.5.
*/
statement: (params: {
tenantid: number;
locationid: number;
subcategoryid?: number;
keyword?: string;
pageno?: number;
pagesize?: number;
}) =>
api.get<StockStatementRow[]>(`${WEB}/products/getstockstatement`, {
tenantid: params.tenantid,
locationid: params.locationid,
subcategoryid: params.subcategoryid,
keyword: params.keyword,
pageno: params.pageno ?? 0,
pagesize: params.pagesize ?? 100,
}),
};

71
src/api/tenants.ts Normal file
View File

@@ -0,0 +1,71 @@
/** Tenant and branch endpoints — the Nearle Admin's provisioning surface. */
import { api, WEB } from './client';
import type { TenantInfo, TenantLocation } from './types';
/** Everything the tenant-onboarding form collects. */
export interface CreateTenantRequest {
tenantname: string;
companyname: string;
primarycontact: string;
primaryemail: string;
locationname: string;
categoryid: number;
subcategoryid?: number;
address: string;
suburb?: string;
city: string;
state: string;
postcode: string;
latitude?: string;
longitude?: string;
moduleid?: number;
status?: string;
}
/** Everything the branch-onboarding form collects. */
export interface CreateBranchRequest {
tenantid: number;
locationname: string;
email?: string;
contactno?: string;
address: string;
suburb?: string;
city: string;
state: string;
postcode: string;
latitude?: string;
longitude?: string;
opentime?: string;
closetime?: string;
deliveryradius?: number;
deliverymins?: number;
status?: string;
}
export const tenantsApi = {
/**
* Every tenant on the platform. Deliberately unscoped — this is the
* Nearle Admin's list, and the backend treats it as the platform-operator
* endpoint rather than a tenant-scoped one.
*/
listAll: () => api.get<TenantInfo[]>(`${WEB}/tenants/getalltenants`),
/** Branches under one tenant. `tenantid` is required — omit it and it 400s. */
locations: (tenantid: number) =>
api.get<TenantLocation[]>(`${WEB}/tenants/gettenantlocations`, { tenantid }),
search: (keyword: string) =>
api.get<TenantInfo[]>(`${WEB}/tenants/searchbykeyword`, { keyword }),
/** Provisions the enterprise and spawns its primary Administrator account. */
createTenant: (body: CreateTenantRequest) =>
api.post<TenantInfo>(`${WEB}/tenants/createtenantlocation`, body),
/** Commissions a branch and spawns a placeholder branch-manager account. */
createBranch: (body: CreateBranchRequest) =>
api.post<TenantLocation>(`${WEB}/tenants/createlocation`, body),
updateBranch: (body: Partial<TenantLocation> & { locationid: number }) =>
api.put<TenantLocation>(`${WEB}/tenants/updatelocation`, body),
};

697
src/api/types.ts Normal file
View File

@@ -0,0 +1,697 @@
/**
* Types for the Fiesta REST API.
*
* Hand-written from the Go structs in `backend_fiesta/models` — there is no
* OpenAPI document to generate from. Field names mirror the `json:` tags
* EXACTLY, including the ones that are misspelled on the wire
* (`applolcationid`, `Subcategoryname`, `Catlougeid`, `Accountname`). Fixing
* them here would only mean the client silently reads undefined.
*/
/* ────────────────────────────────────────────────────────────────────────────
Envelope
──────────────────────────────────────────────────────────────────────────── */
/**
* Every Fiesta handler answers in this shape. `details` is the payload and its
* type varies per endpoint — sometimes an object, sometimes an array, and on
* the create endpoints an echo of what was sent.
*
* `status` is the success flag and `code` repeats the HTTP status in the body.
* Both are checked: several handlers return HTTP 200 with `status: false`.
*/
export interface FiestaEnvelope<T> {
status: boolean;
code: number;
message?: string;
details?: T;
/** Present on the tenant login endpoints. */
tenantform?: boolean;
}
/* ────────────────────────────────────────────────────────────────────────────
Users — models/users.go
──────────────────────────────────────────────────────────────────────────── */
export interface FiestaUser {
userid: number;
authname: string;
configid: number;
authmode: number;
roleid: number;
firstname: string;
lastname: string;
fullname?: string;
email: string;
contactno: string;
address?: string;
suburb?: string;
city?: string;
state?: string;
postcode?: string;
shiftid?: number;
shiftname?: string;
partnerid?: number;
tenantid: number;
locationid: number;
applocationid?: number;
applocation?: string;
status: string;
/** Server-derived platform-operator flag. Never inferred client-side. */
issuperadmin?: boolean;
}
/* ────────────────────────────────────────────────────────────────────────────
Tenants & locations — models/tenant.go
──────────────────────────────────────────────────────────────────────────── */
export interface TenantInfo {
tenantid: number;
locationid: number;
tenantname: string;
locationname: string;
tenanttype?: string;
registrationno?: string;
companyname?: string;
primaryemail?: string;
primarycontact?: string;
locationcontact?: string;
categoryid?: number;
subcategoryid?: number;
address?: string;
suburb?: string;
city?: string;
state?: string;
postcode?: string;
latitude?: string;
longitude?: string;
tenantimage?: string;
tenantinfo?: string;
partnerid?: number;
minorder?: number;
/** Misspelled on the wire — `applolcationid`, not `applocationid`. */
applolcationid?: number;
applocation?: string;
approved?: number;
moduleid?: number;
subcategoryname?: string;
firstname?: string;
lastname?: string;
/** Capitalised on the wire. */
Accountname?: string;
status: string;
}
export interface TenantLocation {
locationid: number;
tenantid: number;
applocationid?: number;
moduleid?: number;
roleid?: number;
locationname: string;
email?: string;
contactno?: string;
latitude?: string;
longitude?: string;
address?: string;
suburb?: string;
city?: string;
state?: string;
postcode?: string;
opentime?: string;
closetime?: string;
partnerid?: number;
deliveryradius?: number;
deliverymins?: number;
cancelsecs?: number;
status: string;
}
/* ────────────────────────────────────────────────────────────────────────────
Global catalogue — models/catalogue.go (the separate pgvector database)
──────────────────────────────────────────────────────────────────────────── */
export interface CatalogueProduct {
id: number;
brand: string;
product_name: string;
title?: string;
description?: string;
category?: string;
image_id?: string;
images?: string[];
size?: string;
variant_key?: string;
product_sku?: string;
sku_source?: string;
/**
* A DISPLAY STRING, not a number — e.g. "₹33-37". The catalogue has no exact
* price; `retailprice` always comes from the store owner at import time.
*/
price_range?: string;
providers?: string[];
fssai_license?: string;
highlights?: string[];
nutrients?: string[];
}
export interface CatalogueBrand {
brand: string;
product_count: number;
}
/** The composite key. A bare `id` repeats across brand tables — never use it alone. */
export interface CatalogueRef {
brand: string;
catalogueid: number;
}
/* ────────────────────────────────────────────────────────────────────────────
Products & stock — models/product.go
──────────────────────────────────────────────────────────────────────────── */
export interface Product {
productid: number;
applocationid?: number;
productlocationid?: number;
tenantid?: number;
categoryid?: number;
categoryname?: string;
subcategoryid?: number;
/** Capitalised on the wire. */
Subcategoryname?: string;
catalogueid?: number;
productname?: string;
productimage?: string;
/** A JSON-encoded array of URLs, held as a string. Parse before use. */
productimages?: string;
productdesc?: string;
productsku?: string;
brandid?: number;
productbrand?: string;
productunit?: string;
unitvalue?: string;
productcost?: number;
taxamount?: number;
taxpercent?: number;
productstock?: number;
quantity?: number;
/** Effective selling price at the scoped location. Read-only, computed. */
price?: number;
retailprice?: number;
productstatus?: string;
locationstatus?: string;
}
export interface ProductCategory {
categoryid: number;
categoryname: string;
}
export interface ProductSubCategory {
subcategoryid: number;
subcategoryname: string;
categoryid?: number;
}
/** Body of POST /web/products/importcatalogueproduct — send an ARRAY of these. */
export interface ImportCatalogueProductRequest {
tenantid: number;
locationid: number;
brand: string;
catalogueid: number;
categoryid: number;
subcategoryid: number;
quantity: number;
stocktype: string;
status: string;
retailprice: number;
productcost: number;
taxpercent: number;
}
/** Body of POST /web/products/createproductlocation — send an ARRAY. Upserts. */
export interface ProductLocationRequest {
tenantid: number;
locationid: number;
productid: number;
/** Misspelled on the wire — `catlougeid`. */
catlougeid?: number;
minquantity?: number;
maxquantity?: number;
price?: number;
status?: string;
}
/** Body of POST /web/products/createproductstock — send an ARRAY. */
export interface ProductStockRequest {
tenantid: number;
locationid: number;
productid: number;
quantity: number;
stocktype: string;
status: string;
}
/* ────────────────────────────────────────────────────────────────────────────
Orders & deliveries — the summary shapes the console reads
──────────────────────────────────────────────────────────────────────────── */
export interface OrderSummary {
totalorders?: number;
delivered?: number;
pending?: number;
cancelled?: number;
revenue?: number;
[key: string]: unknown;
}
export interface LocationOrderSummary {
locationid?: number;
locationname?: string;
totalorders?: number;
delivered?: number;
cancelled?: number;
revenue?: number;
[key: string]: unknown;
}
export interface DeliverySummary {
totaldeliveries?: number;
dispatched?: number;
delivered?: number;
[key: string]: unknown;
}
/* ────────────────────────────────────────────────────────────────────────────
POS — /v1/pos/*
──────────────────────────────────────────────────────────────────────────── */
/** `GET /pos/sales` — verified against `models/pos.go` `PosSalesPage`. */
export interface PosSalesPage {
total?: number;
pageno?: number;
pagesize?: number;
bills?: PosSale[];
}
/**
* One counter bill, from `models/posorder.go` `PosOrders`.
*
* Two identifiers, and they mean different things. `terminalorderid` is a UUID
* minted at the till, globally unique, and the only thing that safely keys a
* row — delivery is at-least-once, so the same bill legitimately arrives more
* than once. `invoicenumber` is the human-facing one and is unique only per
* terminal: a replaced till restarts its series, so duplicates across terminals
* are expected and gaps are normal.
*
* `billedat` is when the sale was rung, NOT when it reached us. A till that was
* offline all day uploads bills dated yesterday, so every daily figure must use
* this rather than `receivedat` — otherwise a sync outage looks like a bumper
* trading day the moment it clears.
*/
export interface PosSale {
posorderid?: number;
terminalorderid?: string;
invoicenumber?: string;
tenantid?: number;
locationid?: number;
terminalid?: string;
cashiername?: string;
customerid?: number;
customername?: string;
customermobile?: string;
billedat?: string;
businessdate?: string;
subtotal?: number;
discount?: number;
taxamount?: number;
roundoff?: number;
/** What the shopper actually paid — the figure every revenue report sums. */
total?: number;
itemcount?: number;
/** The largest tender. `paymentsjson` carries the full split. */
paymentmode?: string;
paymentsjson?: string;
/** Which upload batch carried this bill, for tracing a till's complaint. */
batchid?: string;
receivedat?: string;
}
/**
* One order row, from `models/order.go` `OrderInfo`.
*
* A subset — the struct has ~70 fields including pickup/drop geometry and push
* tokens that no list needs. Note that the delivery half lives here too: rider,
* KMS and every timestamp of the journey are columns on the order, which is why
* the old console served orders and deliveries from one read.
*
* `orderheaderid` is the key, NOT `orderid`. The old console's own comment
* records why: "orderid is not unique".
*/
export interface OrderRow {
orderheaderid: number;
orderid?: string;
tenantid?: number;
locationid?: number;
locationname?: string;
orderdate?: string;
deliverydate?: string;
orderstatus?: string;
deliverystatus?: string;
itemcount?: number;
/** The old console reads `quantity` first; not every writer sets itemcount. */
quantity?: number;
/**
* The order's money, and it moves.
*
* The old console reads `ordervalue || orderamount || deliveryamt` — a
* fallback chain, not indecision. `ordervalue` is on the fuller Orders struct
* (amount + tax + charges − promo), `orderamount` is the goods alone, and
* `deliveryamt` is what a delivery job carries. Which one is populated
* depends on the endpoint the row came through, so all three are typed.
*/
ordervalue?: number;
orderamount?: number;
/** Cash to collect on delivery. Shown only when > 0. */
collectionamt?: number;
deliverycharge?: number;
deliveryamt?: number;
paymenttype?: number;
paymentstatus?: number;
taxamount?: number;
ordernotes?: string;
/** The delivery REGION — a coarser grouping than the branch. */
applocation?: string;
/** Set when the shop finished packing. Sits between confirm and pickup. */
packtime?: string;
starttime?: string;
arrivaltime?: string;
/** Who placed it, and where it is going. */
deliverycustomer?: string;
deliverycontactno?: string;
deliveryaddress?: string;
deliverysuburb?: string;
pickupcustomer?: string;
pickupcontactno?: string;
pickupaddress?: string;
pickupsuburb?: string;
/** The delivery half. Blank on an order nobody has been assigned to. */
deliveryid?: number;
rider?: string;
ridercontactno?: string;
riderkms?: string | number;
assigntime?: string;
pickuptime?: string;
deliverytime?: string;
canceltime?: string;
}
/**
* One till, as it last reported itself.
*
* Every value is a STRING. This is a Redis hash under a TTL, not a table:
* `services/posService.go` types it `[]map[string]string` and hands the hash
* straight back, so numbers arrive as `"14"` and booleans as `"true"`. Typing
* them as numbers here would compile and then silently produce `NaN`, so the
* shape stays honest and the parsing is explicit at the call site.
*
* A terminal that stops refreshing simply disappears from the array — there is
* no reaper job and no stale row claiming "online" after closing time.
*/
export interface PosTerminalHealth {
terminal_id?: string;
location_id?: string;
store_name?: string;
app_version?: string;
/** "online" while refreshing. The MQTT Last Will writes "offline" on power loss. */
status?: string;
/** Queue depth. The number that matters most — see `models/poshealth.go`. */
pending_bills?: string;
pending_registrations?: string;
oldest_pending_at?: string;
today_bills?: string;
today_amount?: string;
last_bill_at?: string;
battery_level?: string;
battery_charging?: string;
storage_free_mb?: string;
printer_reachable?: string;
drawer_status?: string;
/** When the till says it wrote this hash — the till's own clock. */
reported_at?: string;
/**
* When the server received it — the server's clock.
*
* The two disagreeing is itself the signal: a till whose clock is wrong
* writes bills under the wrong business date. See `models/poshealth.go`.
*/
received_at?: string;
/**
* Why the till went offline, present only on an offline record.
*
* On the expired-key stub this is the ONLY field carrying information —
* `{terminal_id, location_id, status, reason}` and nothing else. That
* shape is how a vanished till is told apart from one that declared itself
* offline on its way out; see `isExpiredStub` in `posStatus.ts`.
*/
reason?: string;
[key: string]: string | undefined;
}
/** `GET /pos/health/location` returns the tills at one outlet, newest state first. */
export type PosLocationHealth = PosTerminalHealth[];
/** `GET /pos/sales/summary` — verified against `models/pos.go` `PosSalesSummary`. */
export interface PosSalesSummary {
locationid?: number;
fromdate?: string;
todate?: string;
billcount?: number;
itemcount?: number;
grosssales?: number;
taxcollected?: number;
discountgiven?: number;
roundoff?: number;
averagebill?: number;
bypaymentmode?: { paymentmode?: string; amount?: number; billcount?: number }[];
byday?: { day?: string; amount?: number; billcount?: number }[];
/**
* Which tills contributed. Note the spelling.
*
* `models/posorder.go` declares `PosTerminalTotal.Terminalid` with the tag
* `json:"terminalid"` — no underscore — while the presence payload uses
* `terminal_id`. The two surfaces disagree, and reading this one with the
* presence spelling silently yields `undefined` for every row, which is
* exactly the bug that made the billed-but-absent reconciliation a no-op.
*
* `terminalid` is also `COALESCE`d to `''` in the GROUP BY, so bills with no
* code collapse into a single empty-id row — see `readTerminalId`.
*/
byterminal?: { terminalid?: string; amount?: number; billcount?: number }[];
}
/**
* A store's request for stock, from `models/stockrequest.go`.
*
* Note what is NOT here: reason, requestedby, approvedqty, approvedby,
* approvaldate, remarks. The spec asks for all six; the table has none of them.
* `status` is a free string whose only meaningful value is "Received", which
* posts a stock movement for the FULL `qty` and cannot be overridden.
*/
export interface StockRequest {
requestid: number;
tenantid: number;
tenantname?: string;
locationid: number;
locationname?: string;
productid: number;
productname?: string;
productimage?: string;
qty: number;
status: string;
created?: string;
updated?: string;
}
/** From `models/product.go` `Productstockstatement` — opening/in/out/closing, nothing finer. */
export interface StockStatementRow {
productid: number;
productname?: string;
productimage?: string;
productunit?: string;
unitvalue?: string;
retailprice?: number;
locationid?: number;
opening?: number;
credit?: number;
debit?: number;
closing?: number;
}
/**
* One delivery job, from `models/deliveries.go` `Deliveryinfo`.
*
* A SEPARATE read from the orders list, not a projection of it. The two agree
* on the order id and diverge everywhere else: a delivery carries `ridername`
* (the orders list calls it `rider`), a planned `kms` alongside an `actualkms`,
* and `deliverycharges` — what the rider is paid — alongside `deliveryamt`,
* what the job is worth. None of that exists on the order.
*
* `orderstatus` here walks the DELIVERY ladder, not the order one:
* pending → accepted → arrived → picked → active → delivered, with skipped and
* cancelled off to the side. Colouring it with the order map would be wrong.
*
* Note `Pickupaddress` is capitalised on the wire. That is the Go tag, not a
* typo here — the old console falls back through four fields for the same
* reason, because app-created jobs leave the customer blank but fill the
* address.
*/
export interface DeliveryRow {
deliveryid: number;
orderheaderid?: number;
orderid?: string;
tenantid?: number;
locationid?: number;
locationname?: string;
locationsuburb?: string;
tenantname?: string;
tenantcity?: string;
orderstatus?: string;
deliverydate?: string;
assigntime?: string;
starttime?: string;
arrivaltime?: string;
pickuptime?: string;
deliverytime?: string;
canceltime?: string;
expecteddeliverytime?: string;
itemcount?: number;
orderamount?: number;
pickupcustomer?: string;
pickupcontactno?: string;
/** Capitalised on the wire — see the note above. */
Pickupaddress?: string;
pickupaddress?: string;
pickuplocation?: string;
pickupsuburb?: string;
deliverycustomer?: string;
deliverycontactno?: string;
deliveryaddress?: string;
deliverylocation?: string;
deliverysuburb?: string;
/** What the rider is paid. Distinct from `deliveryamt`, the job's value. */
deliverycharges?: number;
deliveryamt?: number;
deliverytype?: string;
notes?: string;
ordernotes?: string;
ridername?: string;
ridercontact?: string;
/** Planned distance. `actualkms`/`riderkms` is what was really ridden. */
kms?: string;
actualkms?: string;
riderkms?: string;
transitminutes?: number;
}
/* ────────────────────────────────────────────────────────────────────────────
People — back-office staff and till accounts
──────────────────────────────────────────────────────────────────────────── */
/**
* One back-office person, from `GET /tenants/getstaffs` (`models.StaffInfo`).
*
* `password` is DELIBERATELY ABSENT from this type. The endpoint returns it —
* passwords are stored and compared in clear — so declaring it here is all it
* would take for a careless cell to render one. Leaving it off the type means
* the compiler refuses.
*
* `rolename` is resolved server-side and is the only trustworthy label. The
* backend's own comment: "`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."
*/
export interface StaffInfo {
userid: number;
rolename?: string;
roleid?: number;
authname?: string;
firstname?: string;
lastname?: string;
fullname?: string;
email?: string;
contactno?: string;
address?: string;
suburb?: string;
city?: string;
state?: string;
postcode?: string;
tenantid?: number;
locationid?: number;
locationname?: string;
status?: string;
}
/**
* One till account, from `GET /getposusers` (`models.PosUser`).
*
* Note the wire names are snake_case here and camel-ish everywhere else in this
* API — the POS half was written later and to its own convention.
*
* `password` is returned ONLY in the answer to a creation or a reset, never by
* a listing, which is the backend's deliberate shape: "An admin who loses it
* reissues rather than looks it up." We show it once at creation and never
* store or re-read it. `pin` is treated the same way.
*/
export interface PosUser {
user_id: number;
full_name?: string;
first_name?: string;
last_name?: string;
authname?: string;
contactno?: string;
role_id?: number;
role?: string;
has_password?: boolean;
shift_id?: number;
shift_name?: string;
shift_start?: string;
shift_end?: string;
location_id?: number;
status?: string;
/** Present only on a create/reset response. Never on a listing. */
password?: string;
pin?: string;
}
/** `GET /posroles` — the source of the role picker. Never hardcode 7 and 8. */
export interface PosRole {
role_id: number;
/** The lowercase name the create endpoint expects — "supervisor", "cashier". */
role: string;
label: string;
description?: string;
}
/**
* A shift window, from `models.StaffShifts`.
*
* `start_time` / `end_time` are wall-clock `HH:MM` text, not instants — they
* repeat, and a date component on them invites exactly the timezone confusion
* that once filed a day of POS bills under the wrong business date.
*
* `weekdays` is a 7-character mask starting Monday: "1111100" is Mon–Fri.
* Empty means every day.
*/
export interface StaffShift {
staff_shift_id: number;
tenantid?: number;
locationid?: number;
name?: string;
start_time?: string;
end_time?: string;
weekdays?: string;
status?: string;
}