guidence
This commit is contained in:
@@ -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() {
|
||||
<Route path="users" element={<UsersPage />} />
|
||||
<Route path="terminals" element={<TerminalsPage />} />
|
||||
<Route path="uploads" element={<AdminUploadsPage />} />
|
||||
<Route path="profile" element={<ShopProfilePage />} />
|
||||
{/* 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
|
||||
|
||||
@@ -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<StaffInfo>(`${MOB}/tenants/getstaffs`, { tenantid }),
|
||||
api.list<StaffInfo>(`${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<unknown>(`${WEB}/tenants/assignstaff`, body),
|
||||
|
||||
unassign: (body: { tenantid: number; userid: number }) =>
|
||||
api.put<unknown>(`${WEB}/tenants/assignstaff`, { ...body, unassign: true }),
|
||||
|
||||
create: (body: CreateStaffRequest) => api.post<StaffInfo>(`${WEB}/users/create`, body),
|
||||
|
||||
|
||||
@@ -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<TenantInfo>(`${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<TenantLocation> & { locationid: number }) =>
|
||||
api.put<TenantLocation>(`${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<TenantInfo>) =>
|
||||
api.put<unknown>(`${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<TenantInfo>(`${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<unknown>(`${WEB}/tenants/updateownprofile`, body),
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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`. */
|
||||
|
||||
@@ -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<string | null>(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),
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
110
src/features/store-admin/SetupChecklist.tsx
Normal file
110
src/features/store-admin/SetupChecklist.tsx
Normal file
@@ -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 (
|
||||
<Card padding={3} elevation="low">
|
||||
<VStack gap={2}>
|
||||
<VStack gap={0.5}>
|
||||
<HStack justify="between" align="center" gap={2} wrap="wrap">
|
||||
<Text type="label" size="lg" weight="semibold">
|
||||
Set up your shop
|
||||
</Text>
|
||||
<Text
|
||||
type="body"
|
||||
size="sm"
|
||||
color="secondary"
|
||||
hasTabularNumbers
|
||||
>
|
||||
{done} of {steps.length}
|
||||
</Text>
|
||||
</HStack>
|
||||
{/* The one sentence that was missing: which step you are on, and what
|
||||
to do about it. */}
|
||||
{now ? (
|
||||
<Text type="body" size="sm" color="secondary">
|
||||
{now.todo}
|
||||
</Text>
|
||||
) : null}
|
||||
</VStack>
|
||||
|
||||
<VStack gap={0}>
|
||||
{steps.map((step) => {
|
||||
const isNow = step.id === now?.id;
|
||||
return (
|
||||
<Link
|
||||
key={step.id}
|
||||
to={step.href}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
padding: '9px 8px',
|
||||
borderRadius: 8,
|
||||
textDecoration: 'none',
|
||||
color: 'inherit',
|
||||
background: isNow ? 'var(--color-surface-subtle)' : 'transparent',
|
||||
}}
|
||||
>
|
||||
{step.done ? (
|
||||
<Check size={15} style={{ color: 'var(--color-success, #10b981)', flex: 'none' }} />
|
||||
) : (
|
||||
<Circle
|
||||
size={15}
|
||||
style={{
|
||||
color: isNow ? 'var(--color-brand)' : 'var(--color-ink-4)',
|
||||
flex: 'none',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Text
|
||||
type="body"
|
||||
size="sm"
|
||||
{...(isNow ? { weight: 'semibold' as const } : {})}
|
||||
{...(step.done ? { color: 'secondary' as const } : {})}
|
||||
>
|
||||
{step.title}
|
||||
</Text>
|
||||
{step.detail ? (
|
||||
<Text type="body" size="xsm" color="secondary" hasTabularNumbers>
|
||||
{step.detail}
|
||||
</Text>
|
||||
) : null}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</VStack>
|
||||
</VStack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -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: <Store size={13} />,
|
||||
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: <Users size={13} />,
|
||||
note: 'Store and terminal accounts',
|
||||
},
|
||||
{
|
||||
to: '/admin/branches/new',
|
||||
label: 'New branch',
|
||||
icon: <Store size={13} />,
|
||||
note: 'Commission an outlet',
|
||||
},
|
||||
{
|
||||
to: '/admin/terminals',
|
||||
label: 'Terminals',
|
||||
|
||||
@@ -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 ? (
|
||||
<SetupChecklist
|
||||
shop={shop.data}
|
||||
people={people.data ?? []}
|
||||
branches={branches}
|
||||
products={products.data ?? []}
|
||||
pendingUploads={pendingUploads}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{/* ── Revenue, by channel, never summed ──────────────────────────── */}
|
||||
<VStack gap={1.5}>
|
||||
<SectionHeader
|
||||
|
||||
@@ -18,8 +18,9 @@ import { PageBody } from '@/components/PageBody';
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
import { SectionHeader } from '@/components/SectionHeader';
|
||||
import { queryKeys } from '@/queries/keys';
|
||||
import { useTenants } from '@/queries/hooks';
|
||||
import { useStaff, useTenants } from '@/queries/hooks';
|
||||
import type { TenantInfo } from '@/api/types';
|
||||
import { isUnplaced } from '../staffPlacement';
|
||||
|
||||
interface FormState {
|
||||
tenantid: string;
|
||||
@@ -35,6 +36,8 @@ interface FormState {
|
||||
closetime: ISOTimeString;
|
||||
deliveryradius: number;
|
||||
deliverymins: number;
|
||||
/** An existing person to run this outlet. Empty means "spawn a login". */
|
||||
operatorid: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -69,6 +72,7 @@ const EMPTY: FormState = {
|
||||
closetime: isoTime('22:00'),
|
||||
deliveryradius: 5000,
|
||||
deliverymins: 30,
|
||||
operatorid: '',
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -109,6 +113,31 @@ export function OnboardBranchPage() {
|
||||
return [...seen.entries()].map(([id, name]) => ({ 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<K extends keyof FormState>(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() {
|
||||
</Text>
|
||||
</HStack>
|
||||
<Text type="body" color="secondary">
|
||||
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.'}
|
||||
</Text>
|
||||
<HStack gap={1.5} wrap="wrap">
|
||||
<Button
|
||||
@@ -213,12 +247,40 @@ export function OnboardBranchPage() {
|
||||
onChange={set('locationname')}
|
||||
placeholder="e.g. RS Puram Super Hub"
|
||||
/>
|
||||
{/* 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. */}
|
||||
<Selector
|
||||
label="Who runs this outlet"
|
||||
options={operatorOptions}
|
||||
value={form.operatorid}
|
||||
onChange={set('operatorid')}
|
||||
placeholder={
|
||||
unplaced.length > 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}
|
||||
/>
|
||||
<TextInput
|
||||
label="Outlet email"
|
||||
type="email"
|
||||
value={form.email}
|
||||
onChange={set('email')}
|
||||
placeholder="rspuram@kaveri.com"
|
||||
description={
|
||||
Number(form.operatorid) > 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.'
|
||||
}
|
||||
/>
|
||||
<TextInput
|
||||
label="Outlet contact"
|
||||
|
||||
288
src/features/store-admin/pages/ShopProfilePage.tsx
Normal file
288
src/features/store-admin/pages/ShopProfilePage.tsx
Normal file
@@ -0,0 +1,288 @@
|
||||
/**
|
||||
* The merchant's own business record — the first screen able to write it.
|
||||
*
|
||||
* Nothing in Fiesta updated the `tenants` table. Every field was set once at
|
||||
* onboarding by a Nearle Admin and could never be changed by anyone, which is
|
||||
* why, measured across 200 merchants on 31 Aug 2026:
|
||||
*
|
||||
* tenantname 200/200 tenantimage 18/200
|
||||
* primarycontact 199/200 licenseno 0/200
|
||||
* address 200/200 tenantinfo 1/200
|
||||
*
|
||||
* The boring fields are complete because onboarding collects them. What is
|
||||
* missing is the shop's PUBLIC FACE — the photograph a shopper sees, the licence
|
||||
* they are legally shown, the description. So this page leads with those four
|
||||
* and keeps the rest below, rather than opening with twenty fields somebody has
|
||||
* already filled in.
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Button } from '@astryxdesign/core/Button';
|
||||
import { Card } from '@astryxdesign/core/Card';
|
||||
import { HStack } from '@astryxdesign/core/HStack';
|
||||
import { NumberInput } from '@astryxdesign/core/NumberInput';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { TextArea } from '@astryxdesign/core/TextArea';
|
||||
import { TextInput } from '@astryxdesign/core/TextInput';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { CheckCircle2, ImageOff } from 'lucide-react';
|
||||
import { errorMessage } from '@/api/client';
|
||||
import { tenantsApi } from '@/api/tenants';
|
||||
import { useAuth } from '@/auth/AuthContext';
|
||||
import { PageBody } from '@/components/PageBody';
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
import { SectionHeader } from '@/components/SectionHeader';
|
||||
import { useOwnTenant } from '@/queries/hooks';
|
||||
import { queryKeys } from '@/queries/keys';
|
||||
import { profileGaps, PUBLIC_FACE_FIELDS } from '../shopProfile';
|
||||
|
||||
export function ShopProfilePage() {
|
||||
const { user } = useAuth();
|
||||
const tenantid = user?.tenantid ?? 0;
|
||||
const shop = useOwnTenant(tenantid || undefined);
|
||||
const client = useQueryClient();
|
||||
|
||||
const [form, setForm] = useState<Record<string, string>>({});
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [error, setError] = useState<string | null>(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<string, unknown>;
|
||||
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 (
|
||||
<VStack gap={3}>
|
||||
<PageHeader title="Shop profile" />
|
||||
<Card padding={4} elevation="low">
|
||||
<Text type="body" color="secondary">
|
||||
Your account is not linked to a business, so there is no profile to edit.
|
||||
</Text>
|
||||
</Card>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageBody measure="reading">
|
||||
<PageHeader
|
||||
title="Shop profile"
|
||||
description="What shoppers see about your business, and how to reach you."
|
||||
{...(gaps.length > 0
|
||||
? { count: `${gaps.length} still to fill in` }
|
||||
: {})}
|
||||
/>
|
||||
|
||||
{shop.isLoading ? (
|
||||
<Card padding={4} elevation="low">
|
||||
<Text type="body" color="secondary">
|
||||
Reading your business…
|
||||
</Text>
|
||||
</Card>
|
||||
) : (
|
||||
<VStack gap={3}>
|
||||
{/* The four fields that are actually missing, first and on their own.
|
||||
Opening with the twenty already-complete ones would bury them. */}
|
||||
<VStack gap={1.5}>
|
||||
<SectionHeader
|
||||
title="What shoppers see"
|
||||
{...(gaps.length > 0 ? { note: `${gaps.length} missing` } : {})}
|
||||
/>
|
||||
<Card padding={3} elevation="low">
|
||||
<VStack gap={2}>
|
||||
<HStack gap={2} align="center">
|
||||
{form['tenantimage'] ? (
|
||||
<img
|
||||
src={form['tenantimage']}
|
||||
alt=""
|
||||
style={{
|
||||
width: 64,
|
||||
height: 64,
|
||||
borderRadius: 10,
|
||||
objectFit: 'cover',
|
||||
border: '1px solid var(--color-line)',
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
width: 64,
|
||||
height: 64,
|
||||
borderRadius: 10,
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
border: '1px dashed var(--color-line)',
|
||||
color: 'var(--color-ink-4)',
|
||||
}}
|
||||
>
|
||||
<ImageOff size={20} />
|
||||
</div>
|
||||
)}
|
||||
<div style={{ flex: 1 }}>
|
||||
<TextInput
|
||||
label="Shop photo"
|
||||
value={form['tenantimage'] ?? ''}
|
||||
onChange={set('tenantimage')}
|
||||
placeholder="https://…"
|
||||
description="The picture shoppers see beside your shop in the app."
|
||||
/>
|
||||
</div>
|
||||
</HStack>
|
||||
|
||||
<TextInput
|
||||
label="Shop name"
|
||||
value={form['tenantname'] ?? ''}
|
||||
onChange={set('tenantname')}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label="FSSAI / trade licence"
|
||||
value={form['licenseno'] ?? ''}
|
||||
onChange={set('licenseno')}
|
||||
description="Shown to shoppers. Required to display for a food business."
|
||||
/>
|
||||
|
||||
<TextArea
|
||||
label="About the shop"
|
||||
value={form['tenantinfo'] ?? ''}
|
||||
onChange={set('tenantinfo')}
|
||||
placeholder="A line or two — what you sell, what you are known for."
|
||||
/>
|
||||
</VStack>
|
||||
</Card>
|
||||
</VStack>
|
||||
|
||||
<VStack gap={1.5}>
|
||||
<SectionHeader title="Contact and address" />
|
||||
<Card padding={3} elevation="low">
|
||||
<VStack gap={2}>
|
||||
<HStack gap={1.5}>
|
||||
<TextInput
|
||||
label="Phone"
|
||||
value={form['primarycontact'] ?? ''}
|
||||
onChange={set('primarycontact')}
|
||||
/>
|
||||
<TextInput
|
||||
label="Email"
|
||||
value={form['primaryemail'] ?? ''}
|
||||
onChange={set('primaryemail')}
|
||||
/>
|
||||
</HStack>
|
||||
<TextInput
|
||||
label="Address"
|
||||
value={form['address'] ?? ''}
|
||||
onChange={set('address')}
|
||||
/>
|
||||
<HStack gap={1.5}>
|
||||
<TextInput label="Area" value={form['suburb'] ?? ''} onChange={set('suburb')} />
|
||||
<TextInput label="City" value={form['city'] ?? ''} onChange={set('city')} />
|
||||
</HStack>
|
||||
<HStack gap={1.5}>
|
||||
<TextInput label="State" value={form['state'] ?? ''} onChange={set('state')} />
|
||||
<TextInput
|
||||
label="Postcode"
|
||||
value={form['postcode'] ?? ''}
|
||||
onChange={set('postcode')}
|
||||
/>
|
||||
</HStack>
|
||||
<HStack gap={1.5}>
|
||||
<TextInput
|
||||
label="Registration number"
|
||||
value={form['registrationno'] ?? ''}
|
||||
onChange={set('registrationno')}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Minimum order (₹)"
|
||||
value={Number(form['minorder']) || 0}
|
||||
onChange={(value) => set('minorder')(String(value ?? 0))}
|
||||
/>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Card>
|
||||
</VStack>
|
||||
|
||||
{error ? (
|
||||
<Text type="body" size="sm" style={{ color: 'var(--color-error, #d64545)' }}>
|
||||
{error}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
<HStack gap={1.5} align="center">
|
||||
<Button
|
||||
label={save.isPending ? 'Saving…' : 'Save profile'}
|
||||
variant="primary"
|
||||
isLoading={save.isPending}
|
||||
isDisabled={save.isPending}
|
||||
onClick={() => save.mutate()}
|
||||
/>
|
||||
{saved ? (
|
||||
<HStack gap={0.5} align="center">
|
||||
<CheckCircle2 size={15} style={{ color: 'var(--color-success, #10b981)' }} />
|
||||
<Text type="body" size="sm" color="secondary">
|
||||
Saved
|
||||
</Text>
|
||||
</HStack>
|
||||
) : null}
|
||||
{/* Said plainly, because a form that sends every field it renders
|
||||
could otherwise be assumed to erase the ones left blank. */}
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
A field left blank is not changed. {PUBLIC_FACE_FIELDS.length} of these are what
|
||||
shoppers see.
|
||||
</Text>
|
||||
</HStack>
|
||||
</VStack>
|
||||
)}
|
||||
</PageBody>
|
||||
);
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
import { useBranchScope } from '../BranchScope';
|
||||
import { branchLabel } from '../format';
|
||||
import { PersonDrawer, TillDrawer } from '../PeopleDrawers';
|
||||
import { isUnplaced } from '../staffPlacement';
|
||||
|
||||
type Group = 'store' | 'till';
|
||||
|
||||
@@ -284,7 +285,16 @@ function StaffTable({
|
||||
</Td>
|
||||
{showBranch ? (
|
||||
<Td isMuted>
|
||||
{branchLabel(branch?.locationname ?? person.locationname)}
|
||||
{/* Named rather than left blank. Somebody with no branch
|
||||
can sign in and is met by "No store assigned" — a
|
||||
real state, and one only this screen can resolve, so
|
||||
an empty cell would hide the row that needs acting on
|
||||
among the rows that do not. */}
|
||||
{!isUnplaced(person) ? (
|
||||
branchLabel(branch?.locationname ?? person.locationname)
|
||||
) : (
|
||||
<Chip label="Not at a shop" tone="warning" />
|
||||
)}
|
||||
</Td>
|
||||
) : null}
|
||||
<Td>
|
||||
|
||||
155
src/features/store-admin/setupSteps.test.ts
Normal file
155
src/features/store-admin/setupSteps.test.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* The setup checklist, and why it is derived rather than stored.
|
||||
*
|
||||
* A stored "onboarded" flag records what somebody clicked. This records what is
|
||||
* true — which matters because the failure it exists to catch is silent.
|
||||
* Measured against Kmart on 31 Aug 2026: one branch, two products, and BOTH
|
||||
* unpriced, so nothing had ever been sellable. Stuck since July, with nothing
|
||||
* on any screen saying which step they were on.
|
||||
*/
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import { currentStep, isSetupComplete, setupSteps } from './setupSteps';
|
||||
import type { Product, TenantInfo, TenantLocation, StaffInfo } from '@/api/types';
|
||||
|
||||
const branch = { locationid: 1141, tenantid: 1, locationname: 'Kmart' } as TenantLocation;
|
||||
const person = { userid: 1, locationid: 1141 } as StaffInfo;
|
||||
const shop = {
|
||||
tenantname: 'Kmart',
|
||||
tenantimage: 'x.jpg',
|
||||
licenseno: '123',
|
||||
tenantinfo: 'Daily needs',
|
||||
} as TenantInfo;
|
||||
|
||||
const product = (over: Partial<Product> = {}) =>
|
||||
({ productid: 1, price: 100, publishedat: '2026-08-01', productstock: 5, categoryid: 2, ...over }) as Product;
|
||||
|
||||
const ids = (input: Parameters<typeof setupSteps>[0]) =>
|
||||
setupSteps(input).filter((step) => step.done).map((step) => step.id);
|
||||
|
||||
test('a brand-new merchant has nothing done', () => {
|
||||
const steps = setupSteps({ shop: {}, people: [], branches: [], products: [] });
|
||||
assert.deepEqual(ids({ shop: {}, people: [], branches: [], products: [] }), []);
|
||||
assert.equal(currentStep(steps)?.id, 'profile');
|
||||
assert.equal(isSetupComplete(steps), false);
|
||||
});
|
||||
|
||||
/*
|
||||
Kmart, exactly as production had it. Products exist, so the step is done — and
|
||||
"price and release" is where they actually are, which is the sentence nobody
|
||||
was being shown.
|
||||
*/
|
||||
test('an unpriced catalogue stops at "price and release"', () => {
|
||||
const input = {
|
||||
shop,
|
||||
people: [person],
|
||||
branches: [branch],
|
||||
products: [product({ price: 0, retailprice: 0, publishedat: null }), product({ productid: 2, price: 0, retailprice: 0, publishedat: null })],
|
||||
};
|
||||
assert.deepEqual(ids(input), ['profile', 'people', 'branch', 'products']);
|
||||
assert.equal(currentStep(setupSteps(input))?.id, 'priced');
|
||||
});
|
||||
|
||||
// Priced but never released is not priced-and-released. `publishedat` is the
|
||||
// release marker, and the two are separate writes.
|
||||
test('priced without being released does not count', () => {
|
||||
const input = {
|
||||
shop, people: [person], branches: [branch],
|
||||
products: [product({ publishedat: null })],
|
||||
};
|
||||
assert.equal(currentStep(setupSteps(input))?.id, 'priced');
|
||||
});
|
||||
|
||||
test('released and priced but with no stock stops at the shelf', () => {
|
||||
const input = {
|
||||
shop, people: [person], branches: [branch],
|
||||
products: [product({ productstock: 0, quantity: 0 })],
|
||||
};
|
||||
assert.equal(currentStep(setupSteps(input))?.id, 'stocked');
|
||||
});
|
||||
|
||||
/*
|
||||
The app's third gate. A product can be priced, released and stocked and still be
|
||||
invisible, because the browse endpoint rejects categoryid 0 outright — the exact
|
||||
failure that hid seven products across the platform in August.
|
||||
*/
|
||||
test('a product with no category never reaches the app', () => {
|
||||
const input = {
|
||||
shop, people: [person], branches: [branch],
|
||||
products: [product({ categoryid: 0 })],
|
||||
};
|
||||
assert.equal(currentStep(setupSteps(input))?.id, 'onsale');
|
||||
});
|
||||
|
||||
test('a shop selling in the app has finished', () => {
|
||||
const input = { shop, people: [person], branches: [branch], products: [product()] };
|
||||
assert.equal(currentStep(setupSteps(input)), null);
|
||||
assert.equal(isSetupComplete(setupSteps(input)), true);
|
||||
});
|
||||
|
||||
/*
|
||||
Work done out of order still counts. A merchant who imported products before
|
||||
opening their second branch is not held back to step 3 — a stored flag walked
|
||||
in sequence would have done exactly that.
|
||||
*/
|
||||
test('steps completed out of order are still completed', () => {
|
||||
const input = { shop: {}, people: [], branches: [], products: [product()] };
|
||||
const done = ids(input);
|
||||
assert.ok(done.includes('products'));
|
||||
assert.ok(done.includes('onsale'));
|
||||
assert.ok(!done.includes('profile'));
|
||||
});
|
||||
|
||||
// The external wait, named. A sheet sits in the catalogue service's review
|
||||
// queue for hours; an un-ticked step with no explanation reads as the
|
||||
// merchant's mistake.
|
||||
test('an upload awaiting the catalogue service says so', () => {
|
||||
const steps = setupSteps({
|
||||
shop, people: [person], branches: [branch], products: [], pendingUploads: 1,
|
||||
});
|
||||
const products = steps.find((step) => step.id === 'products');
|
||||
assert.equal(products?.done, false);
|
||||
assert.match(String(products?.detail), /waiting for the catalogue service/);
|
||||
});
|
||||
|
||||
// It comes back. A shop that sells out is genuinely not "on sale" any more,
|
||||
// and a permanent tick would be a lie the merchant acts on.
|
||||
test('selling out reopens the last step', () => {
|
||||
const input = {
|
||||
shop, people: [person], branches: [branch],
|
||||
products: [product({ productstock: 0, quantity: 0 })],
|
||||
};
|
||||
assert.equal(isSetupComplete(setupSteps(input)), false);
|
||||
});
|
||||
|
||||
/*
|
||||
A trading shop is not onboarding.
|
||||
|
||||
Measured on 31 Aug 2026: R mart had 20 products on sale, Suriya 6, Ragul 8 —
|
||||
all selling — and all three were missing a licence number, because nothing in
|
||||
the platform could write one until this week. Requiring every step to be done
|
||||
would have dropped a seven-step setup card onto three shops that are set up.
|
||||
*/
|
||||
test('a shop that is selling sees no checklist, even with profile gaps', () => {
|
||||
const steps = setupSteps({
|
||||
shop: { tenantname: 'R mart' }, // no image, licence or description
|
||||
people: [person],
|
||||
branches: [branch],
|
||||
products: [product()],
|
||||
});
|
||||
assert.equal(steps.find((s) => s.id === 'profile')?.done, false);
|
||||
assert.equal(isSetupComplete(steps), true);
|
||||
});
|
||||
|
||||
// And the shop that genuinely cannot sell still sees it — Kmart, two products,
|
||||
// neither priced, stuck since July.
|
||||
test('a shop with nothing on sale still sees the checklist', () => {
|
||||
const steps = setupSteps({
|
||||
shop: { tenantname: 'Kmart' },
|
||||
people: [person],
|
||||
branches: [branch],
|
||||
products: [product({ price: 0, retailprice: 0, publishedat: null })],
|
||||
});
|
||||
assert.equal(isSetupComplete(steps), false);
|
||||
assert.equal(currentStep(steps)?.id, 'profile');
|
||||
});
|
||||
161
src/features/store-admin/setupSteps.ts
Normal file
161
src/features/store-admin/setupSteps.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import type { Product, TenantInfo, TenantLocation } from '@/api/types';
|
||||
import { effectivePrice, isPublished, stockOf } from './productState';
|
||||
import { isProfileComplete } from './shopProfile';
|
||||
import { isUnplaced } from './staffPlacement';
|
||||
import type { StaffInfo } from '@/api/types';
|
||||
|
||||
/**
|
||||
* Getting a shop from "signed in" to "on sale", as a checklist that ticks
|
||||
* itself.
|
||||
*
|
||||
* Every step is DERIVED from live data — never a stored "onboarded" flag. Three
|
||||
* things follow from that, and each of them is why the flag would have been
|
||||
* wrong:
|
||||
*
|
||||
* - It cannot lie. A flag says what somebody clicked; this says what is true.
|
||||
* - Work done out of order still counts. A merchant who imports products
|
||||
* before opening a second branch is not held at step 2.
|
||||
* - It comes back. A shop that sells out of everything is genuinely no longer
|
||||
* at step 5, and should be told so rather than shown a permanent tick.
|
||||
*
|
||||
* Measured against Kmart on 31 Aug 2026: branches and products done, priced
|
||||
* and released 0 of 2 — a real merchant stuck since July with nothing on screen
|
||||
* saying which step they were on.
|
||||
*/
|
||||
export type SetupStepId =
|
||||
| 'profile'
|
||||
| 'people'
|
||||
| 'branch'
|
||||
| 'products'
|
||||
| 'priced'
|
||||
| 'stocked'
|
||||
| 'onsale';
|
||||
|
||||
export interface SetupStep {
|
||||
id: SetupStepId;
|
||||
title: string;
|
||||
/** What to do, when it is not done. Never shown once it is. */
|
||||
todo: string;
|
||||
done: boolean;
|
||||
/** Where the work happens. */
|
||||
href: string;
|
||||
/** A real count, when there is one worth showing. */
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
export interface SetupInput {
|
||||
shop: Partial<TenantInfo> | undefined;
|
||||
people: readonly StaffInfo[];
|
||||
branches: readonly TenantLocation[];
|
||||
products: readonly Product[];
|
||||
/** Uploads still waiting on the catalogue service to release them. */
|
||||
pendingUploads?: number;
|
||||
}
|
||||
|
||||
export function setupSteps(input: SetupInput): SetupStep[] {
|
||||
const { shop, people, branches, products, pendingUploads = 0 } = input;
|
||||
|
||||
const priced = products.filter((product) => effectivePrice(product) > 0 && isPublished(product));
|
||||
const stocked = priced.filter((product) => stockOf(product) > 0);
|
||||
// The three gates the customer app applies, together. A product missing any
|
||||
// of them is invisible to a shopper however complete it looks here.
|
||||
const onSale = stocked.filter((product) => Number(product.categoryid ?? 0) > 0);
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'profile',
|
||||
title: 'Complete your shop profile',
|
||||
todo: 'Add your shop photo and licence — this is what shoppers see.',
|
||||
done: isProfileComplete(shop ?? {}),
|
||||
href: '/admin/profile',
|
||||
},
|
||||
{
|
||||
id: 'people',
|
||||
title: 'Add your people',
|
||||
todo: 'Add whoever will run your shops. You can add them before a branch exists.',
|
||||
done: people.length > 0,
|
||||
href: '/admin/users',
|
||||
...(people.some(isUnplaced)
|
||||
? { detail: `${people.filter(isUnplaced).length} not at a shop yet` }
|
||||
: {}),
|
||||
},
|
||||
{
|
||||
id: 'branch',
|
||||
title: 'Open your first branch',
|
||||
todo: 'Commission an outlet and say who runs it.',
|
||||
done: branches.length > 0,
|
||||
href: '/admin/branches/new',
|
||||
...(branches.length > 0 ? { detail: `${branches.length}` } : {}),
|
||||
},
|
||||
{
|
||||
id: 'products',
|
||||
title: 'Get your products in',
|
||||
todo: 'Import from the catalogue, or upload your own spreadsheet.',
|
||||
done: products.length > 0,
|
||||
href: '/admin/inventory',
|
||||
/* The waiting state, said plainly. A spreadsheet sits in the catalogue
|
||||
service's review queue until one of THEIR admins releases it — usually
|
||||
hours — and a step that just sat un-ticked would read as a mistake the
|
||||
merchant had made. */
|
||||
...(products.length === 0 && pendingUploads > 0
|
||||
? { detail: `${pendingUploads} upload${pendingUploads === 1 ? '' : 's'} waiting for the catalogue service` }
|
||||
: products.length > 0
|
||||
? { detail: `${products.length}` }
|
||||
: {}),
|
||||
},
|
||||
{
|
||||
id: 'priced',
|
||||
title: 'Price and release them',
|
||||
todo: 'A product with no price cannot be rung up, and one not released reaches no shop.',
|
||||
done: priced.length > 0,
|
||||
href: '/admin/inventory',
|
||||
...(products.length > 0 && priced.length < products.length
|
||||
? { detail: `${priced.length} of ${products.length}` }
|
||||
: {}),
|
||||
},
|
||||
{
|
||||
id: 'stocked',
|
||||
title: 'Put stock on the shelf',
|
||||
todo: 'Record what you actually hold — nothing sells at a balance of zero.',
|
||||
done: stocked.length > 0,
|
||||
href: '/admin/inventory',
|
||||
...(priced.length > 0 && stocked.length < priced.length
|
||||
? { detail: `${stocked.length} of ${priced.length}` }
|
||||
: {}),
|
||||
},
|
||||
{
|
||||
id: 'onsale',
|
||||
title: 'See it in the app',
|
||||
todo: 'Once a product is priced, released, stocked and in a category, shoppers can buy it.',
|
||||
done: onSale.length > 0,
|
||||
href: '/admin/inventory',
|
||||
...(onSale.length > 0 ? { detail: `${onSale.length} on sale` } : {}),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** The step a merchant is actually on — the first unfinished one. */
|
||||
export function currentStep(steps: readonly SetupStep[]): SetupStep | null {
|
||||
return steps.find((step) => !step.done) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* True once the shop is actually selling, and the checklist should disappear.
|
||||
*
|
||||
* Keyed on the LAST step rather than on every step, and the difference is not
|
||||
* cosmetic. Measured across the four live merchants on 31 Aug 2026: R mart had
|
||||
* 20 products on sale, Suriya 6, Ragul 8 — all trading perfectly — and all
|
||||
* three were missing a licence number, because until this week nothing in the
|
||||
* platform could write one. Requiring every step would have put a seven-step
|
||||
* "Set up your shop" card on three shops that are already set up.
|
||||
*
|
||||
* A shop with customers buying from it is not onboarding. The profile gap is
|
||||
* real and stays visible on the profile page itself; it is not a reason to tell
|
||||
* a trading merchant they have not started.
|
||||
*
|
||||
* Only Kmart — nothing priced, nothing on sale, stuck since July — sees the
|
||||
* card, which is exactly who it was built for.
|
||||
*/
|
||||
export function isSetupComplete(steps: readonly SetupStep[]): boolean {
|
||||
return steps.find((step) => step.id === 'onsale')?.done === true;
|
||||
}
|
||||
64
src/features/store-admin/shopProfile.test.ts
Normal file
64
src/features/store-admin/shopProfile.test.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Which parts of a shop's profile a shopper never got to see.
|
||||
*
|
||||
* Nothing in Fiesta wrote the `tenants` table — no update path existed at all —
|
||||
* so every field was set once at onboarding and frozen. Measured across 200
|
||||
* merchants on 31 Aug 2026: 18 had a shop photograph, 1 had a description, and
|
||||
* NOT ONE had a licence number, which a food business is required to display.
|
||||
*
|
||||
* Onboarding collects the dull fields well (name, address, phone: 199–200 of
|
||||
* 200), so a profile screen opening with twenty complete fields would bury the
|
||||
* four that matter. This is the list that keeps them at the top and the setup
|
||||
* checklist honest.
|
||||
*/
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import { isProfileComplete, profileGaps, PUBLIC_FACE_FIELDS } from './shopProfile';
|
||||
|
||||
test('a brand-new shop is missing everything a shopper sees', () => {
|
||||
assert.deepEqual(profileGaps({}), [...PUBLIC_FACE_FIELDS]);
|
||||
assert.equal(isProfileComplete({}), false);
|
||||
});
|
||||
|
||||
// The measured shape of a real merchant: onboarding filled the name, nobody
|
||||
// could ever fill the rest.
|
||||
test('the typical shop is missing exactly the public-facing three', () => {
|
||||
const gaps = profileGaps({ tenantname: 'Kmart' });
|
||||
assert.deepEqual(gaps, ['tenantimage', 'licenseno', 'tenantinfo']);
|
||||
});
|
||||
|
||||
test('a complete profile reports no gaps', () => {
|
||||
assert.equal(
|
||||
isProfileComplete({
|
||||
tenantname: 'Kmart',
|
||||
tenantimage: 'https://example.com/shop.jpg',
|
||||
licenseno: '12345678901234',
|
||||
tenantinfo: 'Daily needs and fresh produce.',
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
/*
|
||||
Whitespace is not a licence number. Treating " " as filled would tick a box
|
||||
that should stay open — and this value is shown to shoppers as a legal
|
||||
disclosure, so a blank that reads as complete is worse than an obvious gap.
|
||||
*/
|
||||
test('a field holding only spaces still counts as missing', () => {
|
||||
const gaps = profileGaps({
|
||||
tenantname: 'Kmart',
|
||||
tenantimage: ' ',
|
||||
licenseno: '\t',
|
||||
tenantinfo: '',
|
||||
});
|
||||
assert.deepEqual(gaps, ['tenantimage', 'licenseno', 'tenantinfo']);
|
||||
});
|
||||
|
||||
// A field the endpoint returned as a number, or not at all, is not a string —
|
||||
// and must not throw while a list of shops is rendering.
|
||||
test('a non-string value is treated as missing rather than crashing', () => {
|
||||
assert.doesNotThrow(() =>
|
||||
profileGaps({ tenantname: 1 as unknown as string, licenseno: null as unknown as string }),
|
||||
);
|
||||
assert.ok(profileGaps({ tenantname: 1 as unknown as string }).includes('tenantname'));
|
||||
});
|
||||
45
src/features/store-admin/shopProfile.ts
Normal file
45
src/features/store-admin/shopProfile.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import type { TenantInfo } from '@/api/types';
|
||||
|
||||
/**
|
||||
* The parts of a shop's profile a shopper actually sees.
|
||||
*
|
||||
* Named separately from the rest because the two behave differently. Address,
|
||||
* phone and postcode are collected at onboarding and are essentially always
|
||||
* complete — measured across 200 merchants, 199 or 200 of 200 each. These four
|
||||
* are not, and they are the ones a customer meets:
|
||||
*
|
||||
* tenantimage 18/200
|
||||
* tenantinfo 1/200
|
||||
* licenseno 0/200 — legally displayed for a food business
|
||||
* minorder 1/200
|
||||
*
|
||||
* So a profile screen that opened with twenty already-filled fields would bury
|
||||
* the four that are missing. This list is what lets the page lead with them,
|
||||
* and what the setup checklist counts.
|
||||
*/
|
||||
export const PUBLIC_FACE_FIELDS = [
|
||||
'tenantimage',
|
||||
'licenseno',
|
||||
'tenantinfo',
|
||||
'tenantname',
|
||||
] as const;
|
||||
|
||||
export type PublicFaceField = (typeof PUBLIC_FACE_FIELDS)[number];
|
||||
|
||||
/**
|
||||
* Which of them this shop has not filled in.
|
||||
*
|
||||
* Whitespace counts as empty: a space typed into a field is not a licence
|
||||
* number, and treating it as one would tick a box that should stay open.
|
||||
*/
|
||||
export function profileGaps(shop: Partial<TenantInfo>): PublicFaceField[] {
|
||||
return PUBLIC_FACE_FIELDS.filter((field) => {
|
||||
const value = (shop as unknown as Record<string, unknown>)[field];
|
||||
return typeof value !== 'string' || value.trim() === '';
|
||||
});
|
||||
}
|
||||
|
||||
/** True once nothing a shopper sees is still blank. */
|
||||
export function isProfileComplete(shop: Partial<TenantInfo>): boolean {
|
||||
return profileGaps(shop).length === 0;
|
||||
}
|
||||
56
src/features/store-admin/staffPlacement.test.ts
Normal file
56
src/features/store-admin/staffPlacement.test.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* "Hired, but not at a shop yet" — the state the new onboarding order depends on.
|
||||
*
|
||||
* A merchant should be able to take somebody on before their outlet opens, then
|
||||
* name them when it does. Until now that was impossible in both directions:
|
||||
* `getstaffs` INNER JOINed `tenantlocations`, so an unplaced person never
|
||||
* appeared; and the person form defaulted the branch to `branches[0]`, so one
|
||||
* was always picked for them.
|
||||
*
|
||||
* The person could still sign in, and was met with "No store assigned" — the
|
||||
* one screen that could have fixed it could not see them.
|
||||
*/
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import { isUnplaced, placementOrder } from './staffPlacement';
|
||||
|
||||
// Three spellings of the same fact. 0 is what the column holds, undefined is a
|
||||
// field the endpoint omitted, null is what a LEFT JOIN returns.
|
||||
test('every spelling of "no branch" reads the same', () => {
|
||||
assert.equal(isUnplaced({ locationid: 0 }), true);
|
||||
assert.equal(isUnplaced({}), true);
|
||||
assert.equal(isUnplaced({ locationid: null as unknown as number }), true);
|
||||
});
|
||||
|
||||
test('somebody at a shop is not unplaced', () => {
|
||||
assert.equal(isUnplaced({ locationid: 1172 }), false);
|
||||
});
|
||||
|
||||
// A negative id is not a branch. Treating it as one would hide a corrupt row
|
||||
// among the settled people instead of surfacing it as needing attention.
|
||||
test('a negative branch id counts as unplaced', () => {
|
||||
assert.equal(isUnplaced({ locationid: -1 }), true);
|
||||
});
|
||||
|
||||
test('unplaced people sort first, then by name', () => {
|
||||
const ordered = placementOrder([
|
||||
{ locationid: 1172, fullname: 'Anita' },
|
||||
{ locationid: 0, fullname: 'Suriya' },
|
||||
{ locationid: 1166, fullname: 'Bala' },
|
||||
{ locationid: 0, fullname: 'Meena' },
|
||||
]);
|
||||
assert.deepEqual(
|
||||
ordered.map((p) => p.fullname),
|
||||
['Meena', 'Suriya', 'Anita', 'Bala'],
|
||||
);
|
||||
});
|
||||
|
||||
// Sorting must not mutate what it was given — the list is React state.
|
||||
test('ordering leaves the original list alone', () => {
|
||||
const input = [
|
||||
{ locationid: 1172, fullname: 'Anita' },
|
||||
{ locationid: 0, fullname: 'Suriya' },
|
||||
];
|
||||
placementOrder(input);
|
||||
assert.equal(input[0]?.fullname, 'Anita');
|
||||
});
|
||||
30
src/features/store-admin/staffPlacement.ts
Normal file
30
src/features/store-admin/staffPlacement.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import type { StaffInfo } from '@/api/types';
|
||||
|
||||
/**
|
||||
* Whether a person has been hired but not yet placed at a shop.
|
||||
*
|
||||
* Its own function because the answer has to be identical in three places — the
|
||||
* people list, the branch-opening form, and the setup checklist — and because
|
||||
* `locationid` arrives as 0, as undefined, and (from a LEFT JOIN) as null, all
|
||||
* meaning the same thing.
|
||||
*
|
||||
* This state could not exist until recently. `getstaffs` INNER JOINed
|
||||
* `tenantlocations`, so somebody with no branch matched no row and vanished
|
||||
* from the only screen that could give them one — while still being able to
|
||||
* sign in and be met by "No store assigned".
|
||||
*/
|
||||
export function isUnplaced(person: Pick<StaffInfo, 'locationid'>): boolean {
|
||||
return !person.locationid || person.locationid <= 0;
|
||||
}
|
||||
|
||||
/** The people who can be given a new branch, unplaced first. */
|
||||
export function placementOrder<T extends Pick<StaffInfo, 'locationid' | 'fullname'>>(
|
||||
people: readonly T[],
|
||||
): T[] {
|
||||
return [...people].sort((a, b) => {
|
||||
// Unplaced first: they are the only rows here waiting on somebody.
|
||||
const byPlacement = Number(isUnplaced(b)) - Number(isUnplaced(a));
|
||||
if (byPlacement !== 0) return byPlacement;
|
||||
return (a.fullname ?? '').localeCompare(b.fullname ?? '');
|
||||
});
|
||||
}
|
||||
@@ -1,25 +1,73 @@
|
||||
import { useState } from 'react';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { Button } from '@astryxdesign/core/Button';
|
||||
import { Card } from '@astryxdesign/core/Card';
|
||||
import { TextInput } from '@astryxdesign/core/TextInput';
|
||||
import { HStack } from '@astryxdesign/core/HStack';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { Mail, MapPin, Store, UserRound } from 'lucide-react';
|
||||
import { MapPin, Store, UserRound } from 'lucide-react';
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
import { useAuth } from '@/auth/AuthContext';
|
||||
import { useBranchScope } from '@/features/store-admin/BranchScope';
|
||||
import { tenantsApi } from '@/api/tenants';
|
||||
import { errorMessage } from '@/api/client';
|
||||
import { normaliseMobile } from '@/api/people';
|
||||
|
||||
/**
|
||||
* My account.
|
||||
*
|
||||
* Read-only, because everything on it is. `PUT /users/update` is a GORM struct
|
||||
* update — every zero value is skipped, so a field cannot be cleared — and it
|
||||
* takes a bare `userid` with no tenant check. A form that silently drops half
|
||||
* of what someone types is worse than no form, so the page says who to ask
|
||||
* instead.
|
||||
* Editable now, and it was not. `PUT /users/update` is a GORM struct update —
|
||||
* every zero value skipped, so a field cannot be cleared — and it takes a bare
|
||||
* `userid` with NO tenant check, so a form built on it would let anybody write
|
||||
* anybody's account. `app_users` also keeps identity beside authorisation, so
|
||||
* the same struct write covers `roleid`, `locationid` and `status`: a branch
|
||||
* user could have promoted themselves or moved shop.
|
||||
*
|
||||
* `tenants/updateownprofile` fixes both. It is scoped to the caller's own
|
||||
* account AND their own business in the WHERE clause, and writes four identity
|
||||
* fields and nothing else.
|
||||
*
|
||||
* What stays read-only is deliberate: your shop, and your user id. Which branch
|
||||
* somebody works at is the merchant's decision, made on the people screen —
|
||||
* that is the whole point of hiring first and placing second.
|
||||
*/
|
||||
export function StoreAccountPage() {
|
||||
const { user } = useAuth();
|
||||
const { current } = useBranchScope();
|
||||
|
||||
/* Seeded from the session, which is what the person signed in as. Split on
|
||||
the first space: `app_users` keeps first and last apart, the session
|
||||
carries them joined, and a one-word name is a first name. */
|
||||
const [firstname, setFirstname] = useState(() => (user?.name ?? '').split(' ')[0] ?? '');
|
||||
const [lastname, setLastname] = useState(() =>
|
||||
(user?.name ?? '').split(' ').slice(1).join(' '),
|
||||
);
|
||||
const [email, setEmail] = useState(user?.email ?? '');
|
||||
const [contactno, setContactno] = useState('');
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [problem, setProblem] = useState<string | null>(null);
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: () =>
|
||||
tenantsApi.updateOwnProfile({
|
||||
userid: user?.userid as number,
|
||||
tenantid: user?.tenantid as number,
|
||||
firstname: firstname.trim(),
|
||||
lastname: lastname.trim(),
|
||||
email: email.trim(),
|
||||
contactno: normaliseMobile(contactno),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
setProblem(null);
|
||||
setSaved(true);
|
||||
},
|
||||
onError: (cause) => {
|
||||
setSaved(false);
|
||||
setProblem(errorMessage(cause));
|
||||
},
|
||||
});
|
||||
|
||||
const where = [current?.suburb, current?.city].filter(Boolean).join(', ');
|
||||
|
||||
return (
|
||||
@@ -60,15 +108,49 @@ export function StoreAccountPage() {
|
||||
</VStack>
|
||||
</HStack>
|
||||
|
||||
<VStack gap={1.5} padding={3}>
|
||||
<Line icon={<Mail size={14} />} label="Email" value={user?.email || '—'} />
|
||||
<VStack gap={2} padding={3}>
|
||||
<HStack gap={1.5}>
|
||||
<TextInput label="First name" size="md" value={firstname} onChange={setFirstname} />
|
||||
<TextInput label="Last name" size="md" value={lastname} onChange={setLastname} />
|
||||
</HStack>
|
||||
<TextInput label="Mobile" size="md" value={contactno} onChange={setContactno} />
|
||||
<TextInput label="Email" size="md" value={email} onChange={setEmail} />
|
||||
|
||||
<HStack gap={1.5} align="center">
|
||||
<Button
|
||||
label={save.isPending ? 'Saving…' : 'Save'}
|
||||
variant="primary"
|
||||
size="sm"
|
||||
isLoading={save.isPending}
|
||||
isDisabled={save.isPending || !user?.userid || !user?.tenantid}
|
||||
onClick={() => save.mutate()}
|
||||
/>
|
||||
{saved ? (
|
||||
<Text type="body" size="sm" style={{ color: 'var(--color-success, #10b981)' }}>
|
||||
Saved
|
||||
</Text>
|
||||
) : null}
|
||||
{problem ? (
|
||||
<Text type="body" size="sm" style={{ color: 'var(--color-error, #d64545)' }}>
|
||||
{problem}
|
||||
</Text>
|
||||
) : null}
|
||||
</HStack>
|
||||
</VStack>
|
||||
|
||||
{/* Read-only, and each for a reason. Which shop you work at is the
|
||||
merchant's decision — made on their people screen, not here. */}
|
||||
<VStack gap={1.5} padding={3} style={{ borderTop: '1px solid var(--color-line)' }}>
|
||||
<Line icon={<UserRound size={14} />} label="User id" value={String(user?.userid ?? '—')} />
|
||||
<Line
|
||||
icon={<Store size={14} />}
|
||||
label="Your shop"
|
||||
value={current?.locationname ?? '—'}
|
||||
value={current?.locationname ?? 'Not at a shop yet'}
|
||||
/>
|
||||
<Line icon={<MapPin size={14} />} label="Where" value={where || '—'} />
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
Your shop is set by your store administrator. Ask them if it needs to change.
|
||||
</Text>
|
||||
</VStack>
|
||||
</VStack>
|
||||
</Card>
|
||||
|
||||
@@ -16,6 +16,7 @@ import { posUsersApi, staffApi } from '@/api/people';
|
||||
import { stockApi, type StockRequestQuery } from '@/api/stock';
|
||||
import { tenantsApi, utilsApi, type TenantListQuery } from '@/api/tenants';
|
||||
import { customersApi, type CustomerQuery } from '@/api/customers';
|
||||
import { uploadsApi } from '@/api/uploads';
|
||||
import { queryKeys } from './keys';
|
||||
import { LIVE_REFETCH_MS } from './interval';
|
||||
|
||||
@@ -401,3 +402,29 @@ export function useStaffShifts(tenantid: number | undefined, locationid: number
|
||||
...stable,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The signed-in merchant's own business record.
|
||||
*
|
||||
* `gettenantinfo`, not `listAll`. The latter is `getalltenants` — paginated
|
||||
* across 262 merchants — so a shop on page two was simply not found, and a
|
||||
* profile screen built on it would show nothing with no visible reason why.
|
||||
*/
|
||||
export function useOwnTenant(tenantid: number | undefined) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.tenants.byId(tenantid ?? 0),
|
||||
queryFn: () => tenantsApi.byId(tenantid as number),
|
||||
enabled: Boolean(tenantid),
|
||||
...stable,
|
||||
});
|
||||
}
|
||||
|
||||
/** Upload receipts for a shop — used by the Uploads page and the setup checklist. */
|
||||
export function useUploads(tenantid: number | undefined, locationid?: number) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.uploads.list(tenantid ?? 0, locationid ?? 0),
|
||||
queryFn: () => uploadsApi.list({ tenantid, ...(locationid ? { locationid } : {}) }),
|
||||
enabled: Boolean(tenantid),
|
||||
...stable,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -8,8 +8,14 @@
|
||||
*/
|
||||
|
||||
export const queryKeys = {
|
||||
uploads: {
|
||||
all: ['uploads'] as const,
|
||||
list: (tenantid: number, locationid: number) =>
|
||||
[...queryKeys.uploads.all, 'list', tenantid, locationid] as const,
|
||||
},
|
||||
tenants: {
|
||||
all: ['tenants'] as const,
|
||||
byId: (tenantid: number) => [...queryKeys.tenants.all, 'byId', tenantid] as const,
|
||||
list: (params: Record<string, unknown> = {}) =>
|
||||
[...queryKeys.tenants.all, 'list', params] as const,
|
||||
appCategories: () => [...queryKeys.tenants.all, 'appCategories'] as const,
|
||||
|
||||
Reference in New Issue
Block a user