This commit is contained in:
2026-09-01 12:00:46 +05:30
parent fe78c54eeb
commit a8e0dc069f
20 changed files with 1278 additions and 32 deletions

View File

@@ -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

View File

@@ -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),

View File

@@ -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),
};
/**

View File

@@ -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`. */

View File

@@ -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>

View 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>
);
}

View File

@@ -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',

View File

@@ -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

View File

@@ -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"

View 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>
);
}

View File

@@ -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>

View 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');
});

View 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;
}

View 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'));
});

View 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;
}

View 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');
});

View 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 ?? '');
});
}

View File

@@ -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>

View File

@@ -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,
});
}

View File

@@ -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,