guidence
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user