Initial commit
This commit is contained in:
53
src/api/catalogue.ts
Normal file
53
src/api/catalogue.ts
Normal 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
186
src/api/client.ts
Normal 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
159
src/api/insights.ts
Normal 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
160
src/api/people.ts
Normal 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
213
src/api/products.ts
Normal 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
98
src/api/stock.ts
Normal 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
71
src/api/tenants.ts
Normal 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
697
src/api/types.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user