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

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