From a8e0dc069fbb01219e5befd1610d05137c25035a Mon Sep 17 00:00:00 2001 From: abhishek Date: Tue, 1 Sep 2026 12:00:46 +0530 Subject: [PATCH] guidence --- src/App.tsx | 2 + src/api/people.ts | 34 ++- src/api/tenants.ts | 63 +++- src/api/types.ts | 8 + src/features/store-admin/PeopleDrawers.tsx | 32 +- src/features/store-admin/SetupChecklist.tsx | 110 +++++++ src/features/store-admin/StoreAdminShell.tsx | 18 +- .../store-admin/pages/ConsolePage.tsx | 31 ++ .../store-admin/pages/OnboardBranchPage.tsx | 68 ++++- .../store-admin/pages/ShopProfilePage.tsx | 288 ++++++++++++++++++ src/features/store-admin/pages/UsersPage.tsx | 12 +- src/features/store-admin/setupSteps.test.ts | 155 ++++++++++ src/features/store-admin/setupSteps.ts | 161 ++++++++++ src/features/store-admin/shopProfile.test.ts | 64 ++++ src/features/store-admin/shopProfile.ts | 45 +++ .../store-admin/staffPlacement.test.ts | 56 ++++ src/features/store-admin/staffPlacement.ts | 30 ++ .../store-user/pages/StoreAccountPage.tsx | 100 +++++- src/queries/hooks.ts | 27 ++ src/queries/keys.ts | 6 + 20 files changed, 1278 insertions(+), 32 deletions(-) create mode 100644 src/features/store-admin/SetupChecklist.tsx create mode 100644 src/features/store-admin/pages/ShopProfilePage.tsx create mode 100644 src/features/store-admin/setupSteps.test.ts create mode 100644 src/features/store-admin/setupSteps.ts create mode 100644 src/features/store-admin/shopProfile.test.ts create mode 100644 src/features/store-admin/shopProfile.ts create mode 100644 src/features/store-admin/staffPlacement.test.ts create mode 100644 src/features/store-admin/staffPlacement.ts 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.'}