store user login

This commit is contained in:
2026-08-25 18:00:16 +05:30
parent 1dc582ba07
commit 517a99d577
62 changed files with 8059 additions and 650 deletions

View File

@@ -36,7 +36,19 @@ export const catalogueApi = {
/** 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`),
/** Requires a brand — the backend reads categories from one brand's table. */
categories: (brand: string) =>
api.get<string[]>(`${WEB}/catalogue/getcategories`, { brand }),
/**
* One catalogue row in full — the fields the import leaves behind
* (highlights, nutrients, FSSAI, every image, provider list).
*
* Returns nothing when a re-scrape has retired the source row, which is
* common: the tenant's product is a snapshot and outlives its origin.
*/
product: (brand: string, sku: string) =>
api.get<CatalogueProduct | null>(`${WEB}/catalogue/getproduct`, { brand, sku }),
/**
* The `(brand, catalogueid)` pairs this tenant has already imported, for

74
src/api/customers.ts Normal file
View File

@@ -0,0 +1,74 @@
import { api, WEB } from './client';
/**
* Customers, as one branch sees them.
*
* `gettenantcustomers` genuinely branches on `locationid`: with one it INNER
* JOINs `tenantcustomers` and returns only the people registered against that
* outlet; without one it returns the tenant's whole book. So this is one of the
* few reads where the branch scope is honoured server-side rather than by us.
*
* The pagination is the trap. The controller supplies NO defaults — a missing
* `pageno`/`pagesize` becomes `LIMIT 0 OFFSET 0`, which returns an empty list
* rather than an error, and reads on screen as "this shop has no customers".
* Both are therefore always sent from here, never left to the caller.
*/
export interface CustomerInfo {
customerid: number;
firstname?: string;
lastname?: string;
contactno?: string;
email?: string;
address?: string;
suburb?: string;
city?: string;
state?: string;
landmark?: string;
doorno?: string;
postcode?: string;
deliverylocationid?: number;
tenantlocationid?: number;
applocationid?: number;
status?: string;
}
export interface CustomerQuery {
tenantid: number;
/** Omit for the tenant's whole book. */
locationid?: number;
keyword?: string;
pageno?: number;
pagesize?: number;
}
export const customersApi = {
list: (query: CustomerQuery) =>
api.get<CustomerInfo[]>(`${WEB}/customers/gettenantcustomers`, {
tenantid: query.tenantid,
locationid: query.locationid,
keyword: query.keyword || undefined,
pageno: query.pageno ?? 1,
pagesize: query.pagesize ?? 100,
}),
};
/** A display name that never renders as an empty string. */
export function customerName(customer: CustomerInfo): string {
const name = [customer.firstname, customer.lastname].filter(Boolean).join(' ').trim();
return name || customer.contactno || `Customer ${customer.customerid}`;
}
/**
* Where they are, in the shortest true form.
*
* Door numbers are dropped: "12B" tells a shopkeeper nothing, and the old
* console's fallback of "Coimbatore" for anyone without an address invented a
* locality for every record that had none.
*/
export function customerLocality(customer: CustomerInfo): string {
const parts = [customer.suburb, customer.city].filter(Boolean) as string[];
if (parts.length > 0) return parts.join(', ');
const address = (customer.address ?? '').split(',').map((part) => part.trim());
const meaningful = address.find((part) => part.length > 3 && !/^\d/.test(part));
return meaningful ?? '—';
}

114
src/api/offlineSales.ts Normal file
View File

@@ -0,0 +1,114 @@
import { api, WEB } from './client';
/**
* Counter sales, imported from a spreadsheet.
*
* A sale rung up at a till that has no POS terminal never passes through the
* app, so nothing deducts its stock and nothing counts its revenue. This is the
* one path that carries those sales into the same order table an app order
* lands in, which is what keeps the stock ledger a single source of truth.
*/
/** One line of the downloaded workbook — a product stocked at one branch. */
export interface SaleTemplateRow {
tenantid: number;
locationid: number;
locationname: string;
productid: number;
productname: string;
productunit?: string;
unitvalue?: string;
categoryname?: string;
currentstock: number;
price: number;
taxpercent: number;
}
export interface SaleTemplateLocation {
locationid: number;
locationname: string;
productcount: number;
}
export interface SaleTemplate {
tenantid: number;
/** 0 when the workbook spans every branch of the tenant. */
locationid: number;
locations: SaleTemplateLocation[];
products: SaleTemplateRow[];
}
export interface OfflineSaleItemInput {
productid: number;
productname?: string;
qtysold: number;
unitprice?: number;
discountamount?: number;
taxpercent?: number;
}
export interface OfflineSaleBillInput {
locationid: number;
billno: string;
saledate?: string;
paymentmode?: string;
customername?: string;
customermobile?: string;
remarks?: string;
items: OfflineSaleItemInput[];
}
export interface OfflineSaleResult {
locationid: number;
locationname: string;
billno: string;
/** `imported` · `duplicate` · `failed`. */
status: string;
orderid?: string;
orderheaderid?: number;
itemcount?: number;
amount?: number;
message?: string;
}
export interface OfflineSalesUploadResponse {
imported: number;
duplicate: number;
failed: number;
totalamount: number;
results: OfflineSaleResult[];
}
export const offlineSalesApi = {
/**
* The workbook's contents.
*
* Deliberately unpaged by the backend — it is a worksheet, and a page of it
* would be a worksheet with rows missing. `locationid` 0 spans every branch;
* a specific one is validated against the tenant before it answers.
*/
template: (tenantid: number, locationid?: number) =>
api.get<SaleTemplate>(`${WEB}/products/getsaletemplate`, {
tenantid,
locationid: locationid ?? 0,
}),
/**
* Import the filled-in workbook.
*
* `locationid` is a **constraint, not a destination**: left at 0 each bill
* goes to the branch its own rows name, and set to a branch every bill naming
* a different one is refused. It is the only place in the whole backend that
* holds a store user to their own store, so the Store user workspace must
* always send it.
*
* Re-uploading the same file is safe: a bill number already recorded for that
* branch comes back as `duplicate` and its stock is not deducted twice.
*/
upload: (body: {
tenantid: number;
locationid: number;
userid: number;
bills: OfflineSaleBillInput[];
}) => api.post<OfflineSalesUploadResponse>(`${WEB}/orders/uploadofflinesales`, body),
};

View File

@@ -78,8 +78,43 @@ export const productsApi = {
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),
/**
* Price a product and release it to the shops.
*
* There is NO `locationid` — deliberately, on the backend's side. It reads
* the tenant's active outlets itself, because "a console that sent its own
* list could publish to a subset by omission"
* (`productPublishRepository.go:50`). One call sets this price at every
* branch and also writes `products.retailprice` and `taxpercent`.
*
* Refuses `price <= 0`.
*/
publish: (body: {
tenantid: number;
productid: number;
price: number;
taxpercent: number;
}) => api.post<unknown>(`${WEB}/products/publishproduct`, body),
/**
* Clear `publishedat`. Narrower than it sounds — the till and the customer
* app do not filter on this column, so this hides the product from the store
* catalogue view and nothing else. See `Product.publishedat`.
*/
unpublish: (body: { tenantid: number; productid: number }) =>
api.post<unknown>(`${WEB}/products/unpublishproduct`, body),
/**
* The tenant's real category list, synthesised from products in use.
*
* Not `getproductcategories` — that reads a master table missing rows for
* categoryids live in production, hardcoded to `moduleid = 2`, unscoped.
*/
tenantCategories: (tenantid: number) =>
api.get<{ categoryid: number; categoryname: string }[]>(
`${WEB}/products/gettenantcategories`,
{ tenantid },
),
/** Unlinks from the store. The product row and its order history survive. */
removeFromStore: (body: { tenantid: number; locationid: number; productid: number }) =>

View File

@@ -40,7 +40,35 @@ export interface StockRequestQuery {
pagesize?: number;
}
export interface CreateStockRequest {
tenantid: number;
locationid: number;
productid: number;
qty: number;
/** Carried so the admin's queue can name the branch without a second read. */
locationname?: string;
productname?: string;
}
export const stockApi = {
/**
* A branch asks its admin for stock.
*
* The only write a Store user has against inventory, and deliberately so:
* nothing here moves the ledger. `status` is always Pending — the backend
* defaults to it when blank, but sending it makes the intent explicit rather
* than relying on a default that a later release could change.
*
* There is no reason field, no requester and no wanted-by date in
* `stockrequests`, so the request carries a product and a quantity and
* nothing else. Do not invent the rest in the UI.
*/
create: (body: CreateStockRequest) =>
api.post<StockRequest>(`${WEB}/products/createstockrequest`, {
...body,
status: STOCK_REQUEST_STATUS.pending,
}),
requests: (query: StockRequestQuery) =>
api.get<StockRequest[]>(`${WEB}/products/getstockrequests`, {
tenantid: query.tenantid,

View File

@@ -20,6 +20,8 @@ export interface CreateTenantRequest {
latitude?: string;
longitude?: string;
moduleid?: number;
/** The city this merchant trades in. `app_location`, not a branch. */
applocationid?: number;
status?: string;
}
@@ -43,13 +45,45 @@ export interface CreateBranchRequest {
status?: string;
}
export interface TenantListQuery {
pageno?: number;
pagesize?: number;
/** `Active` / `InActive`. Omitted, the backend returns every state. */
status?: string;
applocationid?: number;
tenanttype?: string;
keyword?: 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`),
listAll: (query: TenantListQuery = {}) =>
api.get<TenantInfo[]>(`${WEB}/tenants/getalltenants`, {
pageno: query.pageno ?? 1,
pagesize: query.pagesize ?? 100,
status: query.status,
applocationid: query.applocationid,
tenanttype: query.tenanttype,
keyword: query.keyword,
}),
/**
* Tenants by approval state — the only way to see the ones awaiting it.
*
* `status=pending` is not a status at all: the handler branches on the word
* and queries `approved = 0` instead (`tenantRepository.go:45-77`). Anything
* else means `approved = 1 AND status = ?`. So an unapproved merchant is
* invisible to every other endpoint, including `getalltenants`.
*
* Nothing can approve one over HTTP. `approved` is writable only at creation,
* so this list is a queue to work from, not one to act on.
*/
byApproval: (status: 'pending' | 'Active' | 'InActive', keyword?: string) =>
api.get<TenantInfo[]>(`${WEB}/tenants/search`, { status, keyword }),
/** Branches under one tenant. `tenantid` is required — omit it and it 400s. */
locations: (tenantid: number) =>
@@ -58,14 +92,100 @@ export const tenantsApi = {
search: (keyword: string) =>
api.get<TenantInfo[]>(`${WEB}/tenants/searchbykeyword`, { keyword }),
/** Provisions the enterprise and spawns its primary Administrator account. */
/**
* Provisions the enterprise, its first outlet, and the primary Administrator
* account — one transaction writing `tenants`, `ordersequences`, `app_users`
* (roleid 1, configid forced to 1), `customers`, `customerlocations` and
* `tenantcustomers`.
*
* `createtenantuser`, NOT `createtenantlocation`. The latter takes a
* `Tenantlocations` and writes a BRANCH under a tenant that already exists —
* pointing the merchant form at it created an outlet and no merchant.
*
* The primary outlet is NESTED. The backend reads it off
* `Tenants.Tenantlocations` and creates it in the same transaction, so a
* tenant can never exist without somewhere to trade from.
*/
createTenant: (body: CreateTenantRequest) =>
api.post<TenantInfo>(`${WEB}/tenants/createtenantlocation`, body),
api.post<TenantInfo>(`${WEB}/tenants/createtenantuser`, toTenantBody(body)),
/** Commissions a branch and spawns a placeholder branch-manager account. */
/**
* Commissions a branch and spawns its login (roleid 0, empty password).
*
* `createtenantlocation`, not `createlocation`: only this one returns the
* created row, and the new `locationid` is what a QR code and every
* follow-up write need. `createlocation` answers 201 with a message and no
* `details` at all.
*/
createBranch: (body: CreateBranchRequest) =>
api.post<TenantLocation>(`${WEB}/tenants/createlocation`, body),
api.post<TenantLocation>(`${WEB}/tenants/createtenantlocation`, body),
updateBranch: (body: Partial<TenantLocation> & { locationid: number }) =>
api.put<TenantLocation>(`${WEB}/tenants/updatelocation`, body),
};
/**
* The merchant form, in the shape `models.Tenants` expects.
*
* `configid` and `applocationid` are sent because the account this call spawns
* is looked up by `configid` at every sign-in, and `applocationid` is the city
* the tenant trades in. `approved: 1` and `status: 'Active'` are set here
* because they can only ever be set here — there is no update or approve
* endpoint, so a tenant created unapproved stays unapproved forever.
*/
function toTenantBody(form: CreateTenantRequest): Record<string, unknown> {
return {
tenantname: form.tenantname,
companyname: form.companyname,
primaryemail: form.primaryemail,
primarycontact: form.primarycontact,
categoryid: form.categoryid,
subcategoryid: form.subcategoryid ?? 0,
address: form.address,
suburb: form.suburb ?? '',
city: form.city,
state: form.state,
postcode: form.postcode,
latitude: form.latitude ?? '',
longitude: form.longitude ?? '',
configid: 1,
moduleid: form.moduleid ?? 2,
applocationid: form.applocationid ?? 1,
approved: 1,
status: form.status ?? 'Active',
// The primary outlet, created in the same transaction. Its address
// defaults to the tenant's — a merchant's first shop is at the address
// they just typed far more often than not, and it can be edited after.
tenantlocations: {
locationname: form.locationname,
email: form.primaryemail,
contactno: form.primarycontact,
address: form.address,
suburb: form.suburb ?? '',
city: form.city,
state: form.state,
postcode: form.postcode,
latitude: form.latitude ?? '',
longitude: form.longitude ?? '',
applocationid: form.applocationid ?? 1,
status: 'Active',
},
};
}
/** One row of the `app_category` master — the business categories a tenant picks from. */
export interface AppCategory {
categoryid: number;
categoryname: string;
}
export const utilsApi = {
/**
* The business-category master.
*
* Read rather than hardcoded. The old console typed four values into the
* form and never called this, which means a category added to the master is
* invisible to onboarding until someone edits the frontend.
*/
appCategories: () => api.get<AppCategory[]>(`${WEB}/utils/getappcategories`),
};

View File

@@ -201,6 +201,16 @@ export interface Product {
retailprice?: number;
productstatus?: string;
locationstatus?: string;
/**
* When this product was released to the shops. NULL while it sits in the
* admin catalogue awaiting a price.
*
* Selected only by `getlocationproducts`, and NOTHING in the backend filters
* on it — the till gates on price and status instead
* (`posRepository.go:735`). So unpublishing does not withdraw a product from
* the POS or the customer app, only from this console's catalogue view.
*/
publishedat?: string | null;
}
export interface ProductCategory {