store user login
This commit is contained in:
@@ -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
74
src/api/customers.ts
Normal 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
114
src/api/offlineSales.ts
Normal 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),
|
||||
};
|
||||
@@ -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 }) =>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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`),
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user