diff --git a/src/App.tsx b/src/App.tsx
index a44c6c9..c41cc2e 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -42,6 +42,7 @@ 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'));
const AdminUploadsPage = named('UploadsPage', () => import('@/features/store-admin/pages/UploadsPage'));
+const ShopProfilePage = named('ShopProfilePage', () => import('@/features/store-admin/pages/ShopProfilePage'));
/* The Store user workspace reuses the merchant's four pages, pinned to one
branch by `BranchScopeProvider pin=`. Only what a shop does differently is
@@ -124,6 +125,7 @@ export function App() {
} />
} />
} />
+ } />
{/* Catches `/admin/dashboard` and anything else that does not resolve.
Without this, an unknown sub-path escapes to the global `*`, which
redirects to this role's HOME_ROUTE — and if that is itself an
diff --git a/src/api/people.ts b/src/api/people.ts
index 1f4e1aa..0ac4946 100644
--- a/src/api/people.ts
+++ b/src/api/people.ts
@@ -21,7 +21,7 @@
* account ends up holding a role that matches nothing.
*/
-import { api, MOB, WEB } from './client';
+import { api, WEB } from './client';
import type { PosRole, PosUser, StaffInfo, StaffShift } from './types';
/* ── Back-office staff ───────────────────────────────────────────────────── */
@@ -57,15 +57,33 @@ export const staffApi = {
* back-office roles and most accounts carry an id absent from it, so any
* mapping written client-side is wrong."
*
- * On the MOB prefix, and that is not a choice: `getstaffs` is registered on
- * `/v1/mob/tenants` only (`tenantroutes.go:35`) and has no `/web` twin, so the
- * path this used to call did not exist. The old console avoided the question
- * by using `users/getallusers`, whose SQL selects no `rolename` at all — it
- * had to map role ids client-side, which is the thing the backend warns
- * against above.
+ * On WEB now. It used to be on MOB because `getstaffs` was registered under
+ * `/v1/mob/tenants` alone and had no `/web` twin — back-office staff were
+ * reachable only through the customer app's door, which is a large part of
+ * why this console never had a people screen. The twin now exists; the MOB
+ * registration is left in place in case something else calls it.
+ *
+ * Returns people with NO branch as well as people with one. That is the whole
+ * point: `locationid` 0 means hired and not yet placed, and the list is
+ * ordered to put them first, because they are the rows needing an action.
*/
list: (tenantid: number) =>
- api.list(`${MOB}/tenants/getstaffs`, { tenantid }),
+ api.list(`${WEB}/tenants/getstaffs`, { tenantid }),
+
+ /**
+ * Put somebody at a branch, or take them off one.
+ *
+ * `unassign` is a separate flag rather than `locationid: 0`, deliberately. A
+ * body that lost the field, a form that posted a blank and a client that
+ * dropped it all arrive as 0 — so a zero alone must never mean "take them off
+ * their shop". The backend refuses it too; this mirrors the rule so the
+ * refusal is not a round trip.
+ */
+ assign: (body: { tenantid: number; userid: number; locationid: number }) =>
+ api.put(`${WEB}/tenants/assignstaff`, body),
+
+ unassign: (body: { tenantid: number; userid: number }) =>
+ api.put(`${WEB}/tenants/assignstaff`, { ...body, unassign: true }),
create: (body: CreateStaffRequest) => api.post(`${WEB}/users/create`, body),
diff --git a/src/api/tenants.ts b/src/api/tenants.ts
index 70f3aff..2be96fa 100644
--- a/src/api/tenants.ts
+++ b/src/api/tenants.ts
@@ -43,6 +43,21 @@ export interface CreateBranchRequest {
deliveryradius?: number;
deliverymins?: number;
status?: string;
+ /**
+ * Who will run this outlet — an existing person, when one has been hired
+ * already.
+ *
+ * Omitted, the backend spawns a login named after the SHOP, on the shop's
+ * email address, one per outlet. That was the only option, and it is why two
+ * people at a counter shared a credential and nothing recorded which of them
+ * did anything.
+ *
+ * A branch must still arrive with SOMEBODY: name a person here, or give an
+ * `email` to spawn one from. The backend refuses a branch with neither,
+ * because an outlet nobody can sign in to is a dead end that shows up in
+ * every list and is noticed by whoever is standing in the shop.
+ */
+ operatorid?: number;
}
export interface TenantListQuery {
@@ -110,7 +125,11 @@ export const tenantsApi = {
api.post(`${WEB}/tenants/createtenantuser`, toTenantBody(body)),
/**
- * Commissions a branch and spawns its login (roleid 0, empty password).
+ * Commissions a branch, and gives it somebody to run it.
+ *
+ * Pass `operatorid` to place a person you have already hired. Without it the
+ * backend spawns a login named after the shop, as it always did — kept so
+ * nothing existing changes, but the named person is the better path.
*
* `createtenantlocation`, not `createlocation`: only this one returns the
* created row, and the new `locationid` is what a QR code and every
@@ -122,6 +141,48 @@ export const tenantsApi = {
updateBranch: (body: Partial & { locationid: number }) =>
api.put(`${WEB}/tenants/updatelocation`, body),
+
+ /**
+ * A merchant editing their own business record.
+ *
+ * The first write path `tenants` has ever had. Before it, everything about a
+ * shop — its name, its photograph, its licence, how to reach it — was set
+ * once at onboarding by a Nearle Admin and could never be changed by anyone.
+ *
+ * Only merchant-owned columns are written; the backend keeps the allowlist
+ * and ignores the rest, so `approved`, `status`, `partnerid` and the billing
+ * fields cannot be set from here even if a caller sends them. Anything
+ * omitted is left alone rather than blanked.
+ */
+ updateProfile: (body: { tenantid: number } & Partial) =>
+ api.put(`${WEB}/tenants/updatetenant`, body),
+
+ /**
+ * One business, by id — how a store login reads its own record.
+ *
+ * Not `listAll`. That is `getalltenants`, paginated over 262 merchants, so a
+ * shop on page two was simply absent and a profile screen built on it would
+ * show nothing for no visible reason.
+ */
+ byId: (tenantid: number) => api.get(`${WEB}/tenants/gettenantinfo`, { tenantid }),
+
+ /**
+ * Somebody editing their own name, mobile or email.
+ *
+ * Not `users/update`. That one writes whatever struct it is handed and checks
+ * only the userid — no tenant, no guard on role or branch — so a self-service
+ * form built on it would let a branch user promote themselves or move shop.
+ * This is scoped to the caller's own account AND business, and writes
+ * identity fields only.
+ */
+ updateOwnProfile: (body: {
+ userid: number;
+ tenantid: number;
+ firstname?: string;
+ lastname?: string;
+ contactno?: string;
+ email?: string;
+ }) => api.put(`${WEB}/tenants/updateownprofile`, body),
};
/**
diff --git a/src/api/types.ts b/src/api/types.ts
index d826fca..40a5d55 100644
--- a/src/api/types.ts
+++ b/src/api/types.ts
@@ -93,6 +93,14 @@ export interface TenantInfo {
longitude?: string;
tenantimage?: string;
tenantinfo?: string;
+ /**
+ * The FSSAI or trade licence.
+ *
+ * Returned by the customer-facing tenant reads and displayed to shoppers,
+ * and across 200 merchants not one had it filled in. For a food business in
+ * India it is a display requirement, not a nicety.
+ */
+ licenseno?: string;
partnerid?: number;
minorder?: number;
/** Misspelled on the wire — `applolcationid`, not `applocationid`. */
diff --git a/src/features/store-admin/PeopleDrawers.tsx b/src/features/store-admin/PeopleDrawers.tsx
index da9022a..4a2a373 100644
--- a/src/features/store-admin/PeopleDrawers.tsx
+++ b/src/features/store-admin/PeopleDrawers.tsx
@@ -191,9 +191,18 @@ export function PersonDrawer({
const [email, setEmail] = useState(row?.email ?? '');
const [contactno, setContactno] = useState(row?.contactno ?? '');
const [roleid, setRoleid] = useState(String(row?.roleid ?? STAFF_ROLES[1]?.id ?? 4));
- const [locationid, setLocationid] = useState(
- String(row?.locationid ?? branches[0]?.locationid ?? 0),
- );
+ /**
+ * No branch by default, rather than the first one.
+ *
+ * `branches[0]` was a guess, and it is the wrong one twice over. It quietly
+ * posted a new hire to whichever outlet happens to sort first, and it made
+ * "not at a shop yet" unreachable — the state a person is in between being
+ * hired and their branch opening, which is the whole point of being able to
+ * add somebody before the shop exists.
+ *
+ * An existing person keeps whatever they have; only a new one starts unplaced.
+ */
+ const [locationid, setLocationid] = useState(String(row?.locationid ?? 0));
const [isActive, setIsActive] = useState((row?.status ?? 'Active').toLowerCase() !== 'inactive');
const [problem, setProblem] = useState(null);
@@ -289,10 +298,19 @@ export function PersonDrawer({
label="Branch"
value={locationid}
onChange={setLocationid}
- options={branches.map((branch) => ({
- value: String(branch.locationid),
- label: branchLabel(branch.locationname),
- }))}
+ placeholder="Not at a shop yet"
+ description="Leave this if their shop does not exist yet — place them when it opens."
+ options={[
+ /* An offered choice, not an absence. Somebody hired before
+ their outlet opens sits here, signs in, and is met by the
+ "No store assigned" screen until a branch is ready for
+ them — which is what makes hiring before opening possible. */
+ { value: '0', label: 'Not at a shop yet' },
+ ...branches.map((branch) => ({
+ value: String(branch.locationid),
+ label: branchLabel(branch.locationname),
+ })),
+ ]}
/>
diff --git a/src/features/store-admin/SetupChecklist.tsx b/src/features/store-admin/SetupChecklist.tsx
new file mode 100644
index 0000000..e0bd88b
--- /dev/null
+++ b/src/features/store-admin/SetupChecklist.tsx
@@ -0,0 +1,110 @@
+/**
+ * Getting a new shop from "signed in" to "on sale", on the page they land on.
+ *
+ * There was no onboarding of any kind in this console — no checklist, no tour,
+ * no first-run anything. A brand-new merchant landed on a dashboard showing
+ * four KPI cards reading ₹0 and a line saying "No branches yet" with nothing to
+ * click, which reads as *everything is fine* rather than *nothing is set up*.
+ *
+ * Kmart is the argument for this screen: one branch, two products, both
+ * unpriced, so nothing had ever been sellable — stuck since July with no
+ * screen anywhere saying which step they were on.
+ *
+ * Every tick is derived from live data, so the card cannot claim work that was
+ * not done, and it disappears entirely once the shop is trading.
+ */
+
+import { Card } from '@astryxdesign/core/Card';
+import { HStack } from '@astryxdesign/core/HStack';
+import { Text } from '@astryxdesign/core/Text';
+import { VStack } from '@astryxdesign/core/VStack';
+import { Link } from 'react-router-dom';
+import { Check, Circle } from 'lucide-react';
+import { setupSteps, currentStep, isSetupComplete, type SetupInput } from './setupSteps';
+
+export function SetupChecklist(input: SetupInput) {
+ const steps = setupSteps(input);
+
+ // Gone once the shop is trading. A checklist that lingers after it is
+ // finished becomes furniture, and stops being read the next time it matters.
+ if (isSetupComplete(steps)) return null;
+
+ const now = currentStep(steps);
+ const done = steps.filter((step) => step.done).length;
+
+ return (
+
+
+
+
+
+ Set up your shop
+
+
+ {done} of {steps.length}
+
+
+ {/* The one sentence that was missing: which step you are on, and what
+ to do about it. */}
+ {now ? (
+
+ {now.todo}
+
+ ) : null}
+
+
+
+ {steps.map((step) => {
+ const isNow = step.id === now?.id;
+ return (
+
+ {step.done ? (
+
+ ) : (
+
+ )}
+
+ {step.title}
+
+ {step.detail ? (
+
+ {step.detail}
+
+ ) : null}
+
+ );
+ })}
+
+
+
+ );
+}
diff --git a/src/features/store-admin/StoreAdminShell.tsx b/src/features/store-admin/StoreAdminShell.tsx
index fd5a815..1906ce5 100644
--- a/src/features/store-admin/StoreAdminShell.tsx
+++ b/src/features/store-admin/StoreAdminShell.tsx
@@ -33,18 +33,30 @@ const NAV: readonly NavEntry[] = [
* place anyone works from.
*/
const MANAGE: readonly MenuEntry[] = [
+ /* First, because it is the first thing a new merchant should do and the one
+ nobody has done: across 200 shops, 18 had a photograph and none had a
+ licence number. It was not neglect — until now nothing in the platform
+ could write the `tenants` table at all. */
{
- to: '/admin/branches/new',
- label: 'New branch',
+ to: '/admin/profile',
+ label: 'Shop profile',
icon: ,
- note: 'Commission an outlet',
+ note: 'What shoppers see about you',
},
+ /* Before "New branch", because that is now the order: hire the person, then
+ open the shop and say who runs it. */
{
to: '/admin/users',
label: 'Users & access',
icon: ,
note: 'Store and terminal accounts',
},
+ {
+ to: '/admin/branches/new',
+ label: 'New branch',
+ icon: ,
+ note: 'Commission an outlet',
+ },
{
to: '/admin/terminals',
label: 'Terminals',
diff --git a/src/features/store-admin/pages/ConsolePage.tsx b/src/features/store-admin/pages/ConsolePage.tsx
index 65da864..acfba79 100644
--- a/src/features/store-admin/pages/ConsolePage.tsx
+++ b/src/features/store-admin/pages/ConsolePage.tsx
@@ -21,11 +21,16 @@ import { KpiCard } from '@/components/KpiCard';
import { PageHeader } from '@/components/PageHeader';
import { SectionHeader } from '@/components/SectionHeader';
import {
+ useLocationProducts,
useLocationSummary,
+ useOwnTenant,
usePosHealthByBranch,
usePosSalesByBranch,
+ useStaff,
useStockRequests,
+ useUploads,
} from '@/queries/hooks';
+import { SetupChecklist } from '../SetupChecklist';
import { useBranchScope } from '../BranchScope';
import { DateRangePicker, presetRange, type RangePreset } from '../DateRangePicker';
import { branchLabel, count, money, percent, share } from '../format';
@@ -68,6 +73,18 @@ export function ConsolePage() {
const orders = useLocationSummary(tenantid || undefined);
const posSales = usePosSalesByBranch(branchIds, range);
const posHealth = usePosHealthByBranch(branchIds);
+ /* The checklist's data. All existing hooks — nothing new was needed on the
+ backend for it, which is most of why it is worth having. */
+ const shop = useOwnTenant(tenantid || undefined);
+ const people = useStaff(tenantid || undefined);
+ const products = useLocationProducts(tenantid || undefined, undefined, 0, { allBranches: true });
+ const uploads = useUploads(tenantid || undefined);
+ /* Drops the catalogue service has not released yet — the wait that makes
+ step 4 look stuck when it is simply not our turn. */
+ const pendingUploads = (uploads.data ?? []).filter(
+ (receipt) => receipt.laststatus === 'pending' && !receipt.runid,
+ ).length;
+
const requests = useStockRequests(
tenantid ? { tenantid, locationid: selected ?? undefined, status: 'Pending' } : undefined,
);
@@ -150,6 +167,20 @@ export function ConsolePage() {
}
/>
+ {/* Setup, before revenue — but only for the merchant, and only until they
+ are trading. A Store user cannot open a branch, hire anybody or edit
+ the shop profile, so the same card in their workspace would be a list
+ of things they must ask somebody else to do. */}
+ {!isPinned ? (
+
+ ) : null}
+
{/* ── Revenue, by channel, never summed ──────────────────────────── */}
({ value: String(id), label: name }));
}, [tenants]);
+ /**
+ * The people this merchant has hired who are not at a shop yet.
+ *
+ * Only the unplaced are offered. Moving somebody off a running branch to open
+ * a new one is a real decision with a consequence at the old shop, and it
+ * belongs on the people screen where that consequence is visible — not
+ * halfway down a form about opening hours and delivery radius.
+ */
+ const staff = useStaff(Number(form.tenantid) || undefined);
+ const unplaced = useMemo(() => (staff.data ?? []).filter(isUnplaced), [staff.data]);
+ const operatorOptions = useMemo(
+ () => [
+ { value: '', label: 'Create a login for the outlet' },
+ ...unplaced.map((person) => ({
+ value: String(person.userid),
+ label:
+ person.fullname?.trim() ||
+ `${person.firstname ?? ''} ${person.lastname ?? ''}`.trim() ||
+ person.email ||
+ `User ${person.userid}`,
+ })),
+ ],
+ [unplaced],
+ );
+
function set(key: K) {
return (value: FormState[K]) => setForm((prev) => ({ ...prev, [key]: value }));
}
@@ -145,6 +174,10 @@ export function OnboardBranchPage() {
closetime: form.closetime,
deliveryradius: form.deliveryradius,
deliverymins: form.deliverymins,
+ // Only when somebody was chosen. Sending 0 would read as "no person
+ // named" on the backend, which is the same as omitting it — but being
+ // explicit here keeps the two paths visibly separate.
+ ...(Number(form.operatorid) > 0 ? { operatorid: Number(form.operatorid) } : {}),
status: 'Active',
});
}
@@ -162,8 +195,9 @@ export function OnboardBranchPage() {
- A placeholder branch-manager account was created with it. The branch has no catalogue
- yet — products are published to it per store.
+ {Number(form.operatorid) > 0
+ ? 'The person you chose now runs it and can sign in with their own account. The branch has no catalogue yet — products are published to it per store.'
+ : 'A login was created for the outlet itself, using the email above. The branch has no catalogue yet — products are published to it per store.'}
+ {/* Who runs it, before what it is called.
+ A branch has to arrive with somebody — the backend refuses
+ one with neither a person nor an email. Choosing a person
+ already hired is the better half of that: the alternative
+ spawns a login named after the shop, on the shop's email,
+ which is how two people end up sharing one credential. */}
+ 0
+ ? 'Choose someone you have hired'
+ : 'Nobody unplaced — a login will be created'
+ }
+ description={
+ unplaced.length > 0
+ ? 'People you have added who are not at a shop yet.'
+ : 'Add people under Users & access to choose one here. Otherwise the outlet email below becomes the login.'
+ }
+ isDisabled={!form.tenantid}
+ />
0
+ ? 'The shop’s own address. It is no longer the login — the person above is.'
+ : 'Becomes the login for this outlet, since no person was chosen.'
+ }
/>
>({});
+ const [saved, setSaved] = useState(false);
+ const [error, setError] = useState(null);
+
+ /* Seeded once the record arrives, and only for fields the merchant may set.
+ Re-seeding on every render would fight the person typing. */
+ useEffect(() => {
+ if (!shop.data) return;
+ const record = shop.data as unknown as Record;
+ setForm({
+ tenantname: String(record['tenantname'] ?? ''),
+ tenantimage: String(record['tenantimage'] ?? ''),
+ licenseno: String(record['licenseno'] ?? ''),
+ tenantinfo: String(record['tenantinfo'] ?? ''),
+ primarycontact: String(record['primarycontact'] ?? ''),
+ primaryemail: String(record['primaryemail'] ?? ''),
+ registrationno: String(record['registrationno'] ?? ''),
+ address: String(record['address'] ?? ''),
+ suburb: String(record['suburb'] ?? ''),
+ city: String(record['city'] ?? ''),
+ state: String(record['state'] ?? ''),
+ postcode: String(record['postcode'] ?? ''),
+ minorder: String(record['minorder'] ?? ''),
+ });
+ }, [shop.data]);
+
+ const gaps = useMemo(() => profileGaps(shop.data ?? {}), [shop.data]);
+
+ const save = useMutation({
+ mutationFn: () =>
+ tenantsApi.updateProfile({
+ tenantid,
+ ...Object.fromEntries(
+ Object.entries(form).map(([key, value]) =>
+ // Numbers as numbers. `minorder` sent as "150" would be written to
+ // an integer column as text and rejected by the driver.
+ key === 'minorder' ? [key, Number(value) || 0] : [key, value.trim()],
+ ),
+ ),
+ }),
+ onSuccess: async () => {
+ setError(null);
+ setSaved(true);
+ await client.invalidateQueries({ queryKey: queryKeys.tenants.all });
+ },
+ onError: (cause) => {
+ setSaved(false);
+ setError(errorMessage(cause));
+ },
+ });
+
+ function set(key: string) {
+ return (value: string) => {
+ setSaved(false);
+ setForm((prev) => ({ ...prev, [key]: value }));
+ };
+ }
+
+ if (!tenantid) {
+ return (
+
+
+
+
+ Your account is not linked to a business, so there is no profile to edit.
+
+
+
+ );
+ }
+
+ return (
+
+ 0
+ ? { count: `${gaps.length} still to fill in` }
+ : {})}
+ />
+
+ {shop.isLoading ? (
+
+
+ Reading your business…
+
+
+ ) : (
+
+ {/* The four fields that are actually missing, first and on their own.
+ Opening with the twenty already-complete ones would bury them. */}
+
+ 0 ? { note: `${gaps.length} missing` } : {})}
+ />
+
+
+
+ {form['tenantimage'] ? (
+
+ ) : (
+