From 517a99d577726f186fa76af0b58ce0b91d50387d Mon Sep 17 00:00:00 2001 From: abhishek Date: Tue, 25 Aug 2026 18:00:16 +0530 Subject: [PATCH] store user login --- _inv.txt | 0 _sync.zip | 0 _their-src.tgz | 0 _to_delete/ProductsPage.tsx | 0 package-lock.json | 13 +- src/App.tsx | 70 +- src/api/catalogue.ts | 14 +- src/api/customers.ts | 74 ++ src/api/offlineSales.ts | 114 +++ src/api/products.ts | 39 +- src/api/stock.ts | 28 + src/api/tenants.ts | 130 +++- src/api/types.ts | 10 + src/auth/roles.ts | 5 +- src/auth/session.ts | 60 +- src/components/KpiCard.tsx | 79 ++- src/components/SectionHeader.tsx | 21 +- src/components/shell/AppShell.tsx | 24 +- src/components/shell/AssistantPanel.tsx | 179 ++++- src/components/shell/assistantWidth.ts | 94 +++ src/demo/fixtures.ts | 220 +++++- src/demo/index.ts | 83 +++ .../nearle-admin/import/ImportDrawer.tsx | 77 +- .../nearle-admin/import/SheetImportPanel.tsx | 1 - .../pages/GlobalCataloguePage.tsx | 3 +- .../nearle-admin/pages/OnboardTenantPage.tsx | 26 +- .../nearle-admin/pages/StoreDetailPage.tsx | 1 - .../nearle-admin/pages/StoresPage.tsx | 241 ++++++- src/features/store-admin/BranchScope.tsx | 40 +- .../store-admin/CatalogueImportDrawer.tsx | 280 ++++++++ src/features/store-admin/Drawer.tsx | 135 ++++ src/features/store-admin/PeopleDrawers.tsx | 49 +- src/features/store-admin/ProductCard.tsx | 369 ++++++++++ src/features/store-admin/ProductDrawer.tsx | 362 ++++++++++ src/features/store-admin/ProductsPanel.tsx | 483 +++++++++++++ src/features/store-admin/PublishDialog.tsx | 247 +++++++ .../store-admin/SheetUploadDrawer.tsx | 36 + src/features/store-admin/format.ts | 2 +- .../offlineSales/CounterSalesDrawer.tsx | 464 ++++++++++++ .../offlineSales/salesSheet.test.ts | 163 +++++ .../store-admin/offlineSales/salesSheet.ts | 555 +++++++++++++++ .../store-admin/pages/ConsolePage.tsx | 137 ++-- .../store-admin/pages/InventoryPage.tsx | 353 ++++------ .../store-admin/pages/OnboardBranchPage.tsx | 3 - .../store-admin/pages/ReportsPage.tsx | 108 +-- src/features/store-admin/pages/SalesPage.tsx | 233 ++++--- src/features/store-admin/pages/UsersPage.tsx | 94 +-- src/features/store-admin/productState.ts | 103 +++ src/features/store-user/RequestDialog.tsx | 148 ++++ src/features/store-user/StoreQrDrawer.tsx | 140 ++++ src/features/store-user/StoreUserShell.tsx | 205 ++++++ .../store-user/pages/StoreAccountPage.tsx | 117 ++++ .../store-user/pages/StoreCustomersPage.tsx | 293 ++++++++ .../store-user/pages/StoreProductsPage.tsx | 659 ++++++++++++++++++ .../store-user/pages/StoreStaffPage.tsx | 236 +++++++ src/features/store-user/qr.test.ts | 99 +++ src/features/store-user/qr.ts | 537 ++++++++++++++ src/features/store-user/requestState.ts | 93 +++ src/features/store-user/ui.tsx | 219 ++++++ src/index.css | 47 +- src/queries/hooks.ts | 78 ++- src/queries/keys.ts | 16 +- 62 files changed, 8059 insertions(+), 650 deletions(-) create mode 100644 _inv.txt create mode 100644 _sync.zip create mode 100644 _their-src.tgz create mode 100644 _to_delete/ProductsPage.tsx create mode 100644 src/api/customers.ts create mode 100644 src/api/offlineSales.ts create mode 100644 src/components/shell/assistantWidth.ts create mode 100644 src/features/store-admin/CatalogueImportDrawer.tsx create mode 100644 src/features/store-admin/Drawer.tsx create mode 100644 src/features/store-admin/ProductCard.tsx create mode 100644 src/features/store-admin/ProductDrawer.tsx create mode 100644 src/features/store-admin/ProductsPanel.tsx create mode 100644 src/features/store-admin/PublishDialog.tsx create mode 100644 src/features/store-admin/SheetUploadDrawer.tsx create mode 100644 src/features/store-admin/offlineSales/CounterSalesDrawer.tsx create mode 100644 src/features/store-admin/offlineSales/salesSheet.test.ts create mode 100644 src/features/store-admin/offlineSales/salesSheet.ts create mode 100644 src/features/store-admin/productState.ts create mode 100644 src/features/store-user/RequestDialog.tsx create mode 100644 src/features/store-user/StoreQrDrawer.tsx create mode 100644 src/features/store-user/StoreUserShell.tsx create mode 100644 src/features/store-user/pages/StoreAccountPage.tsx create mode 100644 src/features/store-user/pages/StoreCustomersPage.tsx create mode 100644 src/features/store-user/pages/StoreProductsPage.tsx create mode 100644 src/features/store-user/pages/StoreStaffPage.tsx create mode 100644 src/features/store-user/qr.test.ts create mode 100644 src/features/store-user/qr.ts create mode 100644 src/features/store-user/requestState.ts create mode 100644 src/features/store-user/ui.tsx diff --git a/_inv.txt b/_inv.txt new file mode 100644 index 0000000..e69de29 diff --git a/_sync.zip b/_sync.zip new file mode 100644 index 0000000..e69de29 diff --git a/_their-src.tgz b/_their-src.tgz new file mode 100644 index 0000000..e69de29 diff --git a/_to_delete/ProductsPage.tsx b/_to_delete/ProductsPage.tsx new file mode 100644 index 0000000..e69de29 diff --git a/package-lock.json b/package-lock.json index 3a7e6a9..f6423de 100644 --- a/package-lock.json +++ b/package-lock.json @@ -78,7 +78,6 @@ "integrity": "sha512-rbGaoAGZq1QImY2VWeWQNAh1ZqQa/KLWnmoOdy13lSjzMcAPDS6WrVv9fZfsNDx+DIG2oySLtMikwZk5WFl1Uw==", "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "intl-messageformat": "^11.2.9" }, @@ -119,7 +118,6 @@ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -1078,7 +1076,6 @@ "resolved": "https://registry.npmjs.org/@stylexjs/stylex/-/stylex-0.19.0.tgz", "integrity": "sha512-CnUFp7YMaDLDeemsWOfJgoC/gKM5P/yBNMcpJaE6ChJmXr7s0DJwSeGTTlHJcqqwN9OW1qGtmARWLFhGZN1pTA==", "license": "MIT", - "peer": true, "dependencies": { "css-mediaquery": "^0.1.2", "invariant": "^2.2.4", @@ -1497,7 +1494,6 @@ "integrity": "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~8.3.0" } @@ -1508,7 +1504,6 @@ "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -1945,7 +1940,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.11.12", "caniuse-lite": "^1.0.30001809", @@ -3115,7 +3109,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -3125,7 +3118,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -3145,7 +3137,6 @@ "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz", "integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==", "license": "MIT", - "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -3263,8 +3254,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/redux-thunk": { "version": "3.1.0", @@ -3590,7 +3580,6 @@ "integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.5", diff --git a/src/App.tsx b/src/App.tsx index 40ffc3f..40fafc7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -6,6 +6,7 @@ import { HOME_ROUTE } from '@/auth/roles'; import { LoginPage } from '@/features/auth/LoginPage'; import { NearleAdminShell } from '@/features/nearle-admin/NearleAdminShell'; import { StoreAdminShell } from '@/features/store-admin/StoreAdminShell'; +import { StoreUserShell } from '@/features/store-user/StoreUserShell'; /** * Routes are split per page. @@ -30,6 +31,14 @@ const OnboardBranchPage = named('OnboardBranchPage', () => import('@/features/st const UsersPage = named('UsersPage', () => import('@/features/store-admin/pages/UsersPage')); const TerminalsPage = named('TerminalsPage', () => import('@/features/store-admin/pages/TerminalsPage')); +/* The Store user workspace reuses the merchant's four pages, pinned to one + branch by `BranchScopeProvider pin=`. Only what a shop does differently is + its own file: the store catalogue, its customers, its QR and its account. */ +const StoreProductsPage = named('StoreProductsPage', () => import('@/features/store-user/pages/StoreProductsPage')); +const StoreCustomersPage = named('StoreCustomersPage', () => import('@/features/store-user/pages/StoreCustomersPage')); +const StoreStaffPage = named('StoreStaffPage', () => import('@/features/store-user/pages/StoreStaffPage')); +const StoreAccountPage = named('StoreAccountPage', () => import('@/features/store-user/pages/StoreAccountPage')); + function RouteFallback() { return (
@@ -107,8 +116,32 @@ export function App() { } /> - {/* Not built yet. Named rather than missing, so the redirect has somewhere to land. */} - } /> + {/* Store user — one branch, fixed. Same four destinations as the + merchant, and mostly the same pages: what differs is the scope, not + the shape, so pinning them beats a second set that drifts. */} + + }> + + + + } + > + } /> + } /> + } /> + } /> + } /> + {/* Reached from the account menu. A shop does not commission outlets, + so there is deliberately no `branches/new` here. */} + } /> + } /> + } /> + } /> + } /> + ); } - -function NotBuiltYet({ workspace }: { workspace: string }) { - const { signOut } = useAuth(); - return ( -
-
-

- The {workspace} workspace is not built yet -

-

- This console currently covers the Nearle Admin workspace. Your account signs in - correctly — there is simply nothing here for it to open. -

- -
-
- ); -} diff --git a/src/api/catalogue.ts b/src/api/catalogue.ts index 5826383..7657422 100644 --- a/src/api/catalogue.ts +++ b/src/api/catalogue.ts @@ -36,7 +36,19 @@ export const catalogueApi = { /** Brands with product counts, for the filter chip row. Never hardcode this list. */ brands: () => api.get(`${WEB}/catalogue/getbrands`), - categories: () => api.get(`${WEB}/catalogue/getcategories`), + /** Requires a brand — the backend reads categories from one brand's table. */ + categories: (brand: string) => + api.get(`${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(`${WEB}/catalogue/getproduct`, { brand, sku }), /** * The `(brand, catalogueid)` pairs this tenant has already imported, for diff --git a/src/api/customers.ts b/src/api/customers.ts new file mode 100644 index 0000000..049c2d1 --- /dev/null +++ b/src/api/customers.ts @@ -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(`${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 ?? '—'; +} diff --git a/src/api/offlineSales.ts b/src/api/offlineSales.ts new file mode 100644 index 0000000..962a45e --- /dev/null +++ b/src/api/offlineSales.ts @@ -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(`${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(`${WEB}/orders/uploadofflinesales`, body), +}; diff --git a/src/api/products.ts b/src/api/products.ts index 2563660..f49f8b1 100644 --- a/src/api/products.ts +++ b/src/api/products.ts @@ -78,8 +78,43 @@ export const productsApi = { createProductStock: (rows: ProductStockRequest[]) => api.post(`${WEB}/products/createproductstock`, rows), - publish: (body: { tenantid: number; locationid: number; productid: number }) => - api.post(`${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(`${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(`${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 }) => diff --git a/src/api/stock.ts b/src/api/stock.ts index 5dc0aef..ab9644f 100644 --- a/src/api/stock.ts +++ b/src/api/stock.ts @@ -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(`${WEB}/products/createstockrequest`, { + ...body, + status: STOCK_REQUEST_STATUS.pending, + }), + requests: (query: StockRequestQuery) => api.get(`${WEB}/products/getstockrequests`, { tenantid: query.tenantid, diff --git a/src/api/tenants.ts b/src/api/tenants.ts index 095e132..02c631f 100644 --- a/src/api/tenants.ts +++ b/src/api/tenants.ts @@ -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(`${WEB}/tenants/getalltenants`), + listAll: (query: TenantListQuery = {}) => + api.get(`${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(`${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(`${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(`${WEB}/tenants/createtenantlocation`, body), + api.post(`${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(`${WEB}/tenants/createlocation`, body), + api.post(`${WEB}/tenants/createtenantlocation`, body), updateBranch: (body: Partial & { locationid: number }) => api.put(`${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 { + 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(`${WEB}/utils/getappcategories`), +}; diff --git a/src/api/types.ts b/src/api/types.ts index 7399c66..3093025 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -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 { diff --git a/src/auth/roles.ts b/src/auth/roles.ts index 11dcd4c..096d94f 100644 --- a/src/auth/roles.ts +++ b/src/auth/roles.ts @@ -84,5 +84,8 @@ export const HOME_ROUTE: Record = { export const ROLE_LABEL: Record = { 'nearle-admin': 'Nearle Admin', 'store-admin': 'Store Admin', - 'store-manager': 'Store Manager', + // The backend's own word for it: an `app_users` row with roleid 0, bound to + // one `tenantlocations.locationid`. "Manager" implied a rank the record does + // not carry. + 'store-manager': 'Store user', }; diff --git a/src/auth/session.ts b/src/auth/session.ts index 4c7e033..6616202 100644 --- a/src/auth/session.ts +++ b/src/auth/session.ts @@ -26,31 +26,50 @@ export class PasswordSetupRequiredError extends Error { interface LoginBody { authname: string; password: string; - roleid?: number; - configid?: number; + /** + * Not optional in practice. + * + * The lookup behind every login is `WHERE authname = ? AND configid = ?` + * (`userRepository.go:218`). Omitted, Go receives 0, the query matches no + * row, and every account on the platform answers "Invalid Email". The console + * surface is configid 1 — `createtenantuser` hard-codes it into the account it + * spawns for exactly this reason. + */ + configid: number; } +/** The console's surface. Every account this app can sign in carries it. */ +const CONFIG_ID = 1; + /** - * Signs in against the web login endpoint. + * Signs in. * - * The handler answers HTTP 200 with `status: false` for a wrong password, and - * with `code: 409` plus `details.setup` when no password has been set, so the - * envelope is inspected rather than trusting the HTTP status. + * `applogin`, not `tenant/weblogin`. The latter carries a check the former does + * not — `request.roleid == app_users.roleid` (`userService.go:224`) — and since + * Go's zero value is 0, a request without a roleid means "roleid must be 0". + * That is the branch-user role, so weblogin silently locked out every Store + * Admin and every platform operator with a 403 reading "Unauthorized email". + * + * The handler answers HTTP 200 with `status: false` for most failures, so the + * envelope is inspected rather than the HTTP status. */ export async function login(email: string, password: string): Promise { - const body: LoginBody = { authname: email.trim(), password }; + const body: LoginBody = { authname: email.trim(), password, configid: CONFIG_ID }; const envelope = await api.envelope( - `${WEB}/users/tenant/weblogin`, + `${WEB}/users/applogin`, { method: 'POST', body }, ); + // A brand-new account — `createtenantlocation` spawns branch logins with an + // empty password — answers `status: true` with a 409 and the userid to set + // one against. It is not a failure, it is the first step. if (envelope.code === 409 && envelope.details?.setup === true) { throw new PasswordSetupRequiredError(envelope.details.userid ?? 0); } if (envelope.status !== true || !envelope.details) { - throw new Error(envelope.message ?? 'Sign-in failed'); + throw new Error(loginMessage(envelope.code, envelope.message)); } const session = toSessionUser(envelope.details); @@ -58,6 +77,29 @@ export async function login(email: string, password: string): Promise - - - - {label} - - {icon ? {icon} : null} + + {/* Icon first, then the label; the pill sits opposite it. Both are + fixed to this row so tiles line up whether or not they carry one. */} + {/* A fixed height, so a label that wraps to two lines (a long note + squeezes it) does not push that tile's value below its neighbours'. */} + + + {icon ? ( + + {icon} + + ) : null} + + {label} + + + + {note ? ( + + {note} + + ) : null} @@ -61,24 +100,6 @@ export function KpiCard({ label, value, note, tone = 'neutral', icon }: KpiCardP {value} - - {note ? ( - - {note} - - ) : null} diff --git a/src/components/SectionHeader.tsx b/src/components/SectionHeader.tsx index b44ea05..d5f18e6 100644 --- a/src/components/SectionHeader.tsx +++ b/src/components/SectionHeader.tsx @@ -6,13 +6,16 @@ import { HStack } from '@astryxdesign/core/HStack'; export interface SectionHeaderProps { title: string; /** - * Sits inline beside the title, not beneath it. + * Sits inline beside the title, not beneath it — and only when it carries a + * fact, such as a count. * - * Required rather than optional on purpose: a bare heading tells the reader - * what a block is called but not what it is showing them, and every section - * in this console has something worth saying there. + * It used to be required, on the theory that every section should say what it + * is showing. In practice that produced a line of editorial beside every + * heading — "how today is going", "a queue nobody can see from the shop + * floor" — which reads as noise once you have seen the page twice. The + * heading and the data under it say enough. */ - note: string; + note?: string; /** A control or a "View all" link, right-aligned. */ action?: ReactNode; } @@ -24,9 +27,11 @@ export function SectionHeader({ title, note, action }: SectionHeaderProps) { {title} - - {note} - + {note ? ( + + {note} + + ) : null} {action} diff --git a/src/components/shell/AppShell.tsx b/src/components/shell/AppShell.tsx index ae83b74..11b4bf3 100644 --- a/src/components/shell/AppShell.tsx +++ b/src/components/shell/AppShell.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState, type ReactNode } from 'react'; import { Link, NavLink, Outlet, useLocation } from 'react-router-dom'; -import { Bell, ChevronDown, ChevronLeft, LogOut, Menu, Search, Sparkles, X } from 'lucide-react'; +import { Bell, ChevronDown, ChevronLeft, LogOut, Menu, Search, X } from 'lucide-react'; import { useIsMobile } from '@/hooks/useIsMobile'; import { useAuth } from '@/auth/AuthContext'; import { ROLE_LABEL } from '@/auth/roles'; @@ -49,6 +49,15 @@ export interface AppShellProps { * here for. */ manageItems?: readonly MenuEntry[]; + /** + * Workspace-specific controls in the header, left of the notification bell. + * + * For things a shop reaches from anywhere and that open in place rather than + * navigating — the store's QR code is the first. A page for it would have + * been a fifth destination for something that is looked at, printed once, and + * closed. + */ + headerActions?: ReactNode; } /** @@ -67,7 +76,14 @@ export interface AppShellProps { * control — so those are props. Copying four hundred lines of chrome per role * is how two headers drift apart and stop looking like one product. */ -export function AppShell({ nav, home, navLabel, scopeControl, manageItems }: AppShellProps) { +export function AppShell({ + nav, + home, + navLabel, + scopeControl, + manageItems, + headerActions, +}: AppShellProps) { const { user, signOut } = useAuth(); const { pathname } = useLocation(); @@ -291,6 +307,8 @@ export function AppShell({ nav, home, navLabel, scopeControl, manageItems }: App + {headerActions} + @@ -733,7 +751,7 @@ function initials(name: string): string { * `label` is required, not optional — an icon-only control with no accessible * name is a bug, so the API makes it impossible to omit. */ -function IconButton({ +export function IconButton({ label, children, onClick, diff --git a/src/components/shell/AssistantPanel.tsx b/src/components/shell/AssistantPanel.tsx index 20decc1..4f70d12 100644 --- a/src/components/shell/AssistantPanel.tsx +++ b/src/components/shell/AssistantPanel.tsx @@ -1,6 +1,13 @@ import { useRef, useState } from 'react'; import { useLocation } from 'react-router-dom'; import { ArrowUp, History, Maximize2, Minimize2, PanelRightClose } from 'lucide-react'; +import { + DEFAULT_WIDTH, + expandedWidth, + MAX_WIDTH, + MIN_WIDTH, + useAssistantWidth, +} from './assistantWidth'; /** Per-route context, so the panel knows which page it is sitting beside. */ const CONTEXT: Record< @@ -135,19 +142,57 @@ export function AssistantPanel({ const [isFocused, setIsFocused] = useState(false); const [isExpanded, setIsExpanded] = useState(false); const textareaRef = useRef(null); + const { width, setWidth, reset } = useAssistantWidth(); + const [isDragging, setDragging] = useState(false); const key = Object.keys(CONTEXT).find((entry) => pathname.startsWith(entry)); const context = (key ? CONTEXT[key] : undefined) ?? FALLBACK; - const expandedStyle: React.CSSProperties = isExpanded - ? { - width: '50vw', - maxWidth: 'none', - } - : {}; + /** + * Three widths, in priority order: stacked (the phone layout owns it), + * expanded (the one-click half-screen), then whatever the operator dragged + * it to. The inline width beats the media queries in `index.css`, which is + * the point — those are the default, not the rule. + * + * Height is deliberately NOT set here. It was, briefly — measured off the + * page so a short page got a short panel — but that put a strip of page + * background under the panel on every screen that was not full. The column + * is full height, always, and `.assistant` owns that. + */ + const sizeStyle: React.CSSProperties = isStacked + ? {} + : isExpanded + ? { width: expandedWidth(window.innerWidth), maxWidth: 'none' } + : { width, maxWidth: 'none' }; return ( -