diff --git a/src/api/customers.ts b/src/api/customers.ts
index e5272fa..5b850cd 100644
--- a/src/api/customers.ts
+++ b/src/api/customers.ts
@@ -29,6 +29,24 @@ export interface CustomerInfo {
deliverylocationid?: number;
tenantlocationid?: number;
applocationid?: number;
+ /**
+ * Where the customer is, as text — the column type in `app_customers`.
+ *
+ * Sent by `gettenantcustomers` on 97% of rows (measured across 31 customers
+ * at 7 shops, 2026-09-15) and simply absent from this interface until now, so
+ * the one nearly-complete piece of geography the backend has about a shop's
+ * customers was invisible to every page.
+ */
+ latitude?: string;
+ longitude?: string;
+ /**
+ * Empty on every customer row on the platform — 0 of 31.
+ *
+ * Kept declared because the column exists and a future write could fill it,
+ * but nothing should render an Active/Inactive state from it: a badge that
+ * reads the same on every row is decoration, and one that reads blank is
+ * worse.
+ */
status?: string;
}
diff --git a/src/api/people.ts b/src/api/people.ts
index 0ac4946..bb61d67 100644
--- a/src/api/people.ts
+++ b/src/api/people.ts
@@ -140,11 +140,23 @@ export const posUsersApi = {
* non-array and hands back `[]`, so the page showed "no till accounts" for a
* shop that had them. Same shape trap as `/health/location`.
*/
- list: (tenantid: number, locationid: number) =>
+ list: (tenantid: number, locationid: number, includeInactive = false) =>
api
.get<{ location_id?: number; users?: PosUser[] }>(`${WEB}/tenants/getposusers`, {
tenantid,
locationid,
+ /*
+ Off by default, because that is what every existing caller assumed.
+
+ The listing excludes inactive accounts unless asked
+ (`posUserRepository.go:494` — `LOWER(COALESCE(a.status,'active')) <>
+ 'inactive'`), and `WebListPosUsers` reads `include_inactive` from the
+ query string. Nothing sent it, which had two consequences: a supervisor
+ who switched a cashier off in the drawer watched them disappear from the
+ list with no trace and no way back, and any Status column could only
+ ever render "Active" because that was the only status a row could have.
+ */
+ ...(includeInactive ? { include_inactive: 'true' } : {}),
})
.then((page) => (Array.isArray(page?.users) ? page.users : [])),
diff --git a/src/api/tenants.ts b/src/api/tenants.ts
index d894c3c..a360ebc 100644
--- a/src/api/tenants.ts
+++ b/src/api/tenants.ts
@@ -44,6 +44,31 @@ export interface CreateTenantRequest {
/** Everything the branch-onboarding form collects. */
export interface CreateBranchRequest {
tenantid: number;
+ /**
+ * The delivery region, inherited from the tenant's existing outlets.
+ *
+ * `tenantlocations.applocationid` has no column default, so a create that
+ * omits it stores 0 — and `resolveOfflineLocationContext` in
+ * `orderRepository.go` calls this column "authoritative", with no fallback
+ * anywhere for a zero. It is also copied straight onto the login the backend
+ * spawns for the branch, so the outlet AND the person running it both end up
+ * in no region at all.
+ *
+ * Measured 2026-09-15: 43 of 75 live branches carry 0. Regions are
+ * 1 = Coimbatore, 2 = Madurai, 23 = Nagercoil.
+ */
+ applocationid?: number;
+ /**
+ * Also inherited, and also without a column default.
+ *
+ * `orderRepository.go` documents the consequence in its own comment —
+ * "tenantlocations carries 0 for moduleid/partnerid at outlets whose live
+ * orders nonetheless use non-zero values" — and works around it by copying
+ * the scaffolding off the most recent real order at that outlet. A branch
+ * commissioned five minutes ago has no such order, so the workaround has
+ * nothing to copy and the joins are left to resolve against a zero.
+ */
+ moduleid?: number;
locationname: string;
email?: string;
contactno?: string;
diff --git a/src/features/nearle-admin/pages/GlobalCataloguePage.tsx b/src/features/nearle-admin/pages/GlobalCataloguePage.tsx
index 14894ad..c113f26 100644
--- a/src/features/nearle-admin/pages/GlobalCataloguePage.tsx
+++ b/src/features/nearle-admin/pages/GlobalCataloguePage.tsx
@@ -1,82 +1,21 @@
import { useSearchParams } from 'react-router-dom';
-import { SegmentedControl, SegmentedControlItem } from '@astryxdesign/core/SegmentedControl';
import { VStack } from '@astryxdesign/core/VStack';
-import { PackageSearch, Upload } from 'lucide-react';
import { PageHeader } from '@/components/PageHeader';
import { CatalogueBrowser } from '@/features/catalogue/CatalogueBrowser';
import { SheetImportPanel } from '../import/SheetImportPanel';
/**
* The platform operator's catalogue.
- *
- * TWO MODES, AND THEY ASK FOR DIFFERENT THINGS.
- *
- * - **Browse** is reading. The operator looks at what the FMCG catalogue holds
- * — the photographs, the pack sizes, the FSSAI licences, what is stocked
- * where — and nothing is written. There is no merchant to choose because
- * nothing lands anywhere, so the page is the rail, the search and the grid
- * and nothing else. The per-product Add is gone from here: stocking one
- * shop at a time is the merchant's own job, in Store Admin ▸ Inventory ▸
- * Catalogue.
- *
- * - **Upload sheet** is writing, and it writes the GLOBAL catalogue — not one
- * merchant's shelf. The ingest service parses, enriches and stores rows in
- * the per-brand tables every merchant reads from, and it takes no tenant and
- * no outlet. This mode therefore asks for nothing but a file.
- *
- * The grid, the filters, the cards and the detail drawer are the shared
- * `CatalogueBrowser` — the same ones the Store Admin sees, because it is the
- * same catalogue.
*/
export function GlobalCataloguePage() {
- /*
- The tab is in the URL so it can be linked to.
-
- Uploads has an "Upload spreadsheet" button and this is where that action
- lives for a platform operator — it writes the GLOBAL catalogue, which is not
- the same action as a merchant uploading their own list. Held in local state it
- could only be reached by landing on Browse and pressing a second control,
- which is a detour rather than a flow.
- */
- const [params, setParams] = useSearchParams();
+ const [params] = useSearchParams();
const mode = params.get('tab') === 'sheet' ? 'sheet' : 'catalogue';
- const setMode = (next: 'catalogue' | 'sheet') => {
- const merged = new URLSearchParams(params);
- if (next === 'catalogue') merged.delete('tab');
- else merged.set('tab', next);
- setParams(merged, { replace: true });
- };
return (
- setMode(value as 'catalogue' | 'sheet')}
- size="sm"
- >
- } />
- } />
-
- }
- />
+
{mode === 'sheet' ? (
- /* The merchant and outlet pickers used to sit above this panel, and
- they described a flow that no longer exists.
-
- The ingest endpoint writes the GLOBAL catalogue. It has no concept of
- a tenant or an outlet — putting a product on one shop's shelf with a
- price and opening stock is a separate call (`/api/upload/stores`,
- joined on `image_id`) that is not wired up yet. Two selectors saying
- the upload was "written against one merchant and one outlet" would
- have had someone pick a shop, upload, and then go looking for stock
- that was never going to arrive.
-
- They come back with the inventory step, and mean something then. */
) : (
(EMPTY);
- /**
- * The category list comes from `app_category`, not from four values typed
- * into this file. A category added to the master should appear here without
- * a release.
- */
const categories = useAppCategories();
const [error, setError] = useState(null);
@@ -110,6 +91,8 @@ export function OnboardTenantPage() {
function handleSubmit(event: FormEvent) {
event.preventDefault();
+ if (!isComplete || mutation.isPending) return;
+
setError(null);
mutation.mutate({
tenantname: form.tenantname.trim(),
@@ -118,8 +101,6 @@ export function OnboardTenantPage() {
primarycontact: form.primarycontact.trim(),
primaryemail: form.primaryemail.trim(),
locationname: form.locationname.trim(),
- // `Number('')` is NaN, which serialises to null and is not what the
- // backend means by "uncategorised" — 0 is.
categoryid: Number(form.categoryid) || 0,
address: form.address.trim(),
suburb: form.suburb.trim(),
@@ -133,121 +114,190 @@ export function OnboardTenantPage() {
if (mutation.isSuccess) {
const created = mutation.data;
return (
-
-
-
-
-
-
-
- {form.tenantname} is live
+
+
+
+
+
+
+
+
+
+
+
+ {form.tenantname} is live
+
+
+
+ Its first outlet, {form.locationname}, has been commissioned. The next step is
+ stocking the catalogue — pick products from the global catalogue, or upload the
+ tenant's own list as a spreadsheet.
-
-
- Its first outlet, {form.locationname}, has been commissioned. The next step is
- stocking the catalogue — pick products from the global catalogue, or upload the
- tenant's own list as a spreadsheet.
-
- {/* The storefront code, at the one moment the person who provisioned
- the shop is holding its details.
-
- It was reachable only from the branch user's own header, which
- is the wrong place for it: the code is what puts the store in
- front of a shopper at all — nobody can order from a shop they
- have not scanned — and the person onboarding it is the one who
- sends it to the merchant. `createtenantuser` returns the tenant
- and its primary outlet's id together, so it can be drawn here
- without a second read. */}
- {created?.tenantid && created?.locationid ? (
-
-
+
+
+ ) : null}
+
+
- ) : null}
-
-
-
-
-
+ navigate('/nearle/stores')}
+ />
+
+
+
+
+
);
}
return (
-
-
-