design
This commit is contained in:
@@ -29,6 +29,24 @@ export interface CustomerInfo {
|
||||
deliverylocationid?: number;
|
||||
tenantlocationid?: number;
|
||||
applocationid?: number;
|
||||
/**
|
||||
* Where the customer is, as text — the column type in `app_customers`.
|
||||
*
|
||||
* Sent by `gettenantcustomers` on 97% of rows (measured across 31 customers
|
||||
* at 7 shops, 2026-09-15) and simply absent from this interface until now, so
|
||||
* the one nearly-complete piece of geography the backend has about a shop's
|
||||
* customers was invisible to every page.
|
||||
*/
|
||||
latitude?: string;
|
||||
longitude?: string;
|
||||
/**
|
||||
* Empty on every customer row on the platform — 0 of 31.
|
||||
*
|
||||
* Kept declared because the column exists and a future write could fill it,
|
||||
* but nothing should render an Active/Inactive state from it: a badge that
|
||||
* reads the same on every row is decoration, and one that reads blank is
|
||||
* worse.
|
||||
*/
|
||||
status?: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -140,11 +140,23 @@ export const posUsersApi = {
|
||||
* non-array and hands back `[]`, so the page showed "no till accounts" for a
|
||||
* shop that had them. Same shape trap as `/health/location`.
|
||||
*/
|
||||
list: (tenantid: number, locationid: number) =>
|
||||
list: (tenantid: number, locationid: number, includeInactive = false) =>
|
||||
api
|
||||
.get<{ location_id?: number; users?: PosUser[] }>(`${WEB}/tenants/getposusers`, {
|
||||
tenantid,
|
||||
locationid,
|
||||
/*
|
||||
Off by default, because that is what every existing caller assumed.
|
||||
|
||||
The listing excludes inactive accounts unless asked
|
||||
(`posUserRepository.go:494` — `LOWER(COALESCE(a.status,'active')) <>
|
||||
'inactive'`), and `WebListPosUsers` reads `include_inactive` from the
|
||||
query string. Nothing sent it, which had two consequences: a supervisor
|
||||
who switched a cashier off in the drawer watched them disappear from the
|
||||
list with no trace and no way back, and any Status column could only
|
||||
ever render "Active" because that was the only status a row could have.
|
||||
*/
|
||||
...(includeInactive ? { include_inactive: 'true' } : {}),
|
||||
})
|
||||
.then((page) => (Array.isArray(page?.users) ? page.users : [])),
|
||||
|
||||
|
||||
@@ -44,6 +44,31 @@ export interface CreateTenantRequest {
|
||||
/** Everything the branch-onboarding form collects. */
|
||||
export interface CreateBranchRequest {
|
||||
tenantid: number;
|
||||
/**
|
||||
* The delivery region, inherited from the tenant's existing outlets.
|
||||
*
|
||||
* `tenantlocations.applocationid` has no column default, so a create that
|
||||
* omits it stores 0 — and `resolveOfflineLocationContext` in
|
||||
* `orderRepository.go` calls this column "authoritative", with no fallback
|
||||
* anywhere for a zero. It is also copied straight onto the login the backend
|
||||
* spawns for the branch, so the outlet AND the person running it both end up
|
||||
* in no region at all.
|
||||
*
|
||||
* Measured 2026-09-15: 43 of 75 live branches carry 0. Regions are
|
||||
* 1 = Coimbatore, 2 = Madurai, 23 = Nagercoil.
|
||||
*/
|
||||
applocationid?: number;
|
||||
/**
|
||||
* Also inherited, and also without a column default.
|
||||
*
|
||||
* `orderRepository.go` documents the consequence in its own comment —
|
||||
* "tenantlocations carries 0 for moduleid/partnerid at outlets whose live
|
||||
* orders nonetheless use non-zero values" — and works around it by copying
|
||||
* the scaffolding off the most recent real order at that outlet. A branch
|
||||
* commissioned five minutes ago has no such order, so the workaround has
|
||||
* nothing to copy and the joins are left to resolve against a zero.
|
||||
*/
|
||||
moduleid?: number;
|
||||
locationname: string;
|
||||
email?: string;
|
||||
contactno?: string;
|
||||
|
||||
@@ -1,82 +1,21 @@
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { SegmentedControl, SegmentedControlItem } from '@astryxdesign/core/SegmentedControl';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { PackageSearch, Upload } from 'lucide-react';
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
import { CatalogueBrowser } from '@/features/catalogue/CatalogueBrowser';
|
||||
import { SheetImportPanel } from '../import/SheetImportPanel';
|
||||
|
||||
/**
|
||||
* The platform operator's catalogue.
|
||||
*
|
||||
* TWO MODES, AND THEY ASK FOR DIFFERENT THINGS.
|
||||
*
|
||||
* - **Browse** is reading. The operator looks at what the FMCG catalogue holds
|
||||
* — the photographs, the pack sizes, the FSSAI licences, what is stocked
|
||||
* where — and nothing is written. There is no merchant to choose because
|
||||
* nothing lands anywhere, so the page is the rail, the search and the grid
|
||||
* and nothing else. The per-product Add is gone from here: stocking one
|
||||
* shop at a time is the merchant's own job, in Store Admin ▸ Inventory ▸
|
||||
* Catalogue.
|
||||
*
|
||||
* - **Upload sheet** is writing, and it writes the GLOBAL catalogue — not one
|
||||
* merchant's shelf. The ingest service parses, enriches and stores rows in
|
||||
* the per-brand tables every merchant reads from, and it takes no tenant and
|
||||
* no outlet. This mode therefore asks for nothing but a file.
|
||||
*
|
||||
* The grid, the filters, the cards and the detail drawer are the shared
|
||||
* `CatalogueBrowser` — the same ones the Store Admin sees, because it is the
|
||||
* same catalogue.
|
||||
*/
|
||||
export function GlobalCataloguePage() {
|
||||
/*
|
||||
The tab is in the URL so it can be linked to.
|
||||
|
||||
Uploads has an "Upload spreadsheet" button and this is where that action
|
||||
lives for a platform operator — it writes the GLOBAL catalogue, which is not
|
||||
the same action as a merchant uploading their own list. Held in local state it
|
||||
could only be reached by landing on Browse and pressing a second control,
|
||||
which is a detour rather than a flow.
|
||||
*/
|
||||
const [params, setParams] = useSearchParams();
|
||||
const [params] = useSearchParams();
|
||||
const mode = params.get('tab') === 'sheet' ? 'sheet' : 'catalogue';
|
||||
const setMode = (next: 'catalogue' | 'sheet') => {
|
||||
const merged = new URLSearchParams(params);
|
||||
if (next === 'catalogue') merged.delete('tab');
|
||||
else merged.set('tab', next);
|
||||
setParams(merged, { replace: true });
|
||||
};
|
||||
|
||||
return (
|
||||
<VStack gap={3}>
|
||||
<PageHeader
|
||||
title="Global catalogue"
|
||||
actions={
|
||||
<SegmentedControl
|
||||
label="What to do"
|
||||
value={mode}
|
||||
onChange={(value) => setMode(value as 'catalogue' | 'sheet')}
|
||||
size="sm"
|
||||
>
|
||||
<SegmentedControlItem value="catalogue" label="Browse catalogue" icon={<PackageSearch size={14} />} />
|
||||
<SegmentedControlItem value="sheet" label="Upload sheet" icon={<Upload size={14} />} />
|
||||
</SegmentedControl>
|
||||
}
|
||||
/>
|
||||
<PageHeader title="Global catalogue" />
|
||||
|
||||
{mode === 'sheet' ? (
|
||||
/* The merchant and outlet pickers used to sit above this panel, and
|
||||
they described a flow that no longer exists.
|
||||
|
||||
The ingest endpoint writes the GLOBAL catalogue. It has no concept of
|
||||
a tenant or an outlet — putting a product on one shop's shelf with a
|
||||
price and opening stock is a separate call (`/api/upload/stores`,
|
||||
joined on `image_id`) that is not wired up yet. Two selectors saying
|
||||
the upload was "written against one merchant and one outlet" would
|
||||
have had someone pick a shop, upload, and then go looking for stock
|
||||
that was never going to arrive.
|
||||
|
||||
They come back with the inventory step, and mean something then. */
|
||||
<SheetImportPanel />
|
||||
) : (
|
||||
<CatalogueBrowser
|
||||
|
||||
@@ -8,12 +8,16 @@ import { Selector } from '@astryxdesign/core/Selector';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { TextInput } from '@astryxdesign/core/TextInput';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { AlertCircle, Building2, CheckCircle2, MapPin } from 'lucide-react';
|
||||
import {
|
||||
AlertCircle,
|
||||
ArrowLeft,
|
||||
Building2,
|
||||
CheckCircle2,
|
||||
MapPin,
|
||||
} from 'lucide-react';
|
||||
import { tenantsApi, type CreateTenantRequest } from '@/api/tenants';
|
||||
import { errorMessage } from '@/api/client';
|
||||
import { PageBody } from '@/components/PageBody';
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
import { SectionHeader } from '@/components/SectionHeader';
|
||||
import { queryKeys } from '@/queries/keys';
|
||||
import { StoreQrPanel } from '@/features/qr/StoreQrPanel';
|
||||
import { useAppCategories } from '@/queries/hooks';
|
||||
@@ -21,7 +25,6 @@ import { useAppCategories } from '@/queries/hooks';
|
||||
interface FormState {
|
||||
tenantname: string;
|
||||
companyname: string;
|
||||
/** Who runs the shop. Written to `tenants.firstname` — see `CreateTenantRequest`. */
|
||||
adminname: string;
|
||||
primarycontact: string;
|
||||
primaryemail: string;
|
||||
@@ -41,45 +44,23 @@ const EMPTY: FormState = {
|
||||
primarycontact: '',
|
||||
primaryemail: '',
|
||||
locationname: '',
|
||||
// No default category. `utils/getappcategories` supplies the real list and
|
||||
// this used to pre-select the first id regardless of what the merchant sells.
|
||||
categoryid: '',
|
||||
address: '',
|
||||
suburb: '',
|
||||
// Empty, not pre-filled.
|
||||
//
|
||||
// These carried 'Coimbatore' and 'Tamil Nadu' as VALUES, not placeholders —
|
||||
// so a merchant anywhere else was submitted with the wrong city and state
|
||||
// unless somebody noticed and cleared them. A default that is right most of
|
||||
// the time is worse than a blank field, because it is only checked when it
|
||||
// looks wrong.
|
||||
city: '',
|
||||
state: '',
|
||||
postcode: '',
|
||||
};
|
||||
|
||||
/**
|
||||
* Provision a new merchant tenant.
|
||||
* Onboard Tenant Page
|
||||
*
|
||||
* This registers the enterprise, its first outlet, and spawns the primary
|
||||
* Administrator account — account creation is a side effect of provisioning
|
||||
* here, because that is what the backend does. There is no separate invite.
|
||||
*
|
||||
* Products are NOT collected on this form. Once the tenant exists it has a
|
||||
* tenantid and a locationid, and only then can either import path run: both
|
||||
* `importcatalogueproduct` and `createproductlocation` require the pair. So the
|
||||
* form hands off to the catalogue on success rather than pretending the two
|
||||
* steps are one.
|
||||
* Clean, standard enterprise SaaS onboarding experience using native design system inputs and components.
|
||||
*/
|
||||
export function OnboardTenantPage() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [form, setForm] = useState<FormState>(EMPTY);
|
||||
/**
|
||||
* The category list comes from `app_category`, not from four values typed
|
||||
* into this file. A category added to the master should appear here without
|
||||
* a release.
|
||||
*/
|
||||
const categories = useAppCategories();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -110,6 +91,8 @@ export function OnboardTenantPage() {
|
||||
|
||||
function handleSubmit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
if (!isComplete || mutation.isPending) return;
|
||||
|
||||
setError(null);
|
||||
mutation.mutate({
|
||||
tenantname: form.tenantname.trim(),
|
||||
@@ -118,8 +101,6 @@ export function OnboardTenantPage() {
|
||||
primarycontact: form.primarycontact.trim(),
|
||||
primaryemail: form.primaryemail.trim(),
|
||||
locationname: form.locationname.trim(),
|
||||
// `Number('')` is NaN, which serialises to null and is not what the
|
||||
// backend means by "uncategorised" — 0 is.
|
||||
categoryid: Number(form.categoryid) || 0,
|
||||
address: form.address.trim(),
|
||||
suburb: form.suburb.trim(),
|
||||
@@ -133,121 +114,190 @@ export function OnboardTenantPage() {
|
||||
if (mutation.isSuccess) {
|
||||
const created = mutation.data;
|
||||
return (
|
||||
<VStack gap={3}>
|
||||
<PageHeader
|
||||
title="Tenant provisioned"
|
||||
/>
|
||||
<Card padding={4} elevation="low">
|
||||
<VStack gap={3}>
|
||||
<HStack align="center" gap={1.5}>
|
||||
<CheckCircle2 size={22} style={{ color: 'var(--color-success, #10b981)' }} />
|
||||
<Text type="large" weight="semibold">
|
||||
{form.tenantname} is live
|
||||
<PageBody measure="reading">
|
||||
<VStack gap={3}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/nearle/stores')}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
padding: '4px 0',
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
color: 'var(--color-ink-3, #657081)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<ArrowLeft size={14} />
|
||||
<span>Back to stores</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Card padding={4} elevation="low">
|
||||
<VStack gap={3}>
|
||||
<HStack align="center" gap={1.5}>
|
||||
<CheckCircle2 size={24} style={{ color: 'var(--color-success, #10b981)' }} />
|
||||
<Text type="large" weight="semibold">
|
||||
{form.tenantname} is live
|
||||
</Text>
|
||||
</HStack>
|
||||
<Text type="body" color="secondary">
|
||||
Its first outlet, {form.locationname}, has been commissioned. The next step is
|
||||
stocking the catalogue — pick products from the global catalogue, or upload the
|
||||
tenant's own list as a spreadsheet.
|
||||
</Text>
|
||||
</HStack>
|
||||
<Text type="body" color="secondary">
|
||||
Its first outlet, {form.locationname}, has been commissioned. The next step is
|
||||
stocking the catalogue — pick products from the global catalogue, or upload the
|
||||
tenant's own list as a spreadsheet.
|
||||
</Text>
|
||||
|
||||
{/* The storefront code, at the one moment the person who provisioned
|
||||
the shop is holding its details.
|
||||
|
||||
It was reachable only from the branch user's own header, which
|
||||
is the wrong place for it: the code is what puts the store in
|
||||
front of a shopper at all — nobody can order from a shop they
|
||||
have not scanned — and the person onboarding it is the one who
|
||||
sends it to the merchant. `createtenantuser` returns the tenant
|
||||
and its primary outlet's id together, so it can be drawn here
|
||||
without a second read. */}
|
||||
{created?.tenantid && created?.locationid ? (
|
||||
<Card padding={3} elevation="low" variant="transparent">
|
||||
<StoreQrPanel
|
||||
tenantid={created.tenantid}
|
||||
locationid={created.locationid}
|
||||
locationname={form.locationname}
|
||||
where={[form.suburb, form.city].filter(Boolean).join(', ')}
|
||||
{created?.tenantid && created?.locationid ? (
|
||||
<Card padding={3} elevation="low" variant="transparent">
|
||||
<StoreQrPanel
|
||||
tenantid={created.tenantid}
|
||||
locationid={created.locationid}
|
||||
locationname={form.locationname}
|
||||
where={[form.suburb, form.city].filter(Boolean).join(', ')}
|
||||
/>
|
||||
</Card>
|
||||
) : null}
|
||||
<HStack gap={1.5} wrap="wrap">
|
||||
<Button
|
||||
label="Stock from global catalogue"
|
||||
variant="primary"
|
||||
onClick={() =>
|
||||
navigate(
|
||||
`/nearle/catalogue?tenantid=${created?.tenantid ?? ''}&locationid=${created?.locationid ?? ''}`,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
) : null}
|
||||
<HStack gap={1.5} wrap="wrap">
|
||||
<Button
|
||||
label="Stock from global catalogue"
|
||||
variant="primary"
|
||||
onClick={() =>
|
||||
navigate(
|
||||
`/nearle/catalogue?tenantid=${created?.tenantid ?? ''}&locationid=${created?.locationid ?? ''}`,
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
label="Back to stores"
|
||||
variant="secondary"
|
||||
onClick={() => navigate('/nearle/stores')}
|
||||
/>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Card>
|
||||
</VStack>
|
||||
<Button
|
||||
label="Back to stores"
|
||||
variant="secondary"
|
||||
onClick={() => navigate('/nearle/stores')}
|
||||
/>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Card>
|
||||
</VStack>
|
||||
</PageBody>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageBody measure="reading">
|
||||
<PageHeader
|
||||
title="Onboard tenant"
|
||||
/>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<form onSubmit={handleSubmit} style={{ width: '100%' }}>
|
||||
<VStack gap={3}>
|
||||
{/* Top Back Action & Required Indicator */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 16,
|
||||
}}
|
||||
>
|
||||
<h1 className="sr-only">Onboard Tenant</h1>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/nearle/stores')}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 5,
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
padding: '4px 0',
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
color: 'var(--color-ink-3, #657081)',
|
||||
cursor: 'pointer',
|
||||
transition: 'color 0.15s ease',
|
||||
}}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.color = 'var(--color-brand, #662582)')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.color = 'var(--color-ink-3, #657081)')}
|
||||
>
|
||||
<ArrowLeft size={14} />
|
||||
<span>Back to stores</span>
|
||||
</button>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
padding: '3px 8px',
|
||||
borderRadius: 6,
|
||||
background: 'var(--color-surface-sunken, #f2f4f7)',
|
||||
border: '1px solid var(--color-line, #e0e4ea)',
|
||||
fontSize: 11.5,
|
||||
color: 'var(--color-ink-3, #657081)',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
<span>Fields marked with <span style={{ color: 'var(--color-error, #d64545)', fontWeight: 600 }}>*</span> are required</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Section 1: Business Details Card */}
|
||||
<Card padding={0} elevation="low">
|
||||
<VStack gap={2} padding={3}>
|
||||
<SectionHeader
|
||||
title="Business"
|
||||
action={<Building2 size={17} style={{ color: 'var(--color-slate-400)' }} />}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
paddingBottom: 10,
|
||||
borderBottom: '1px solid var(--color-line, #e0e4ea)',
|
||||
}}
|
||||
>
|
||||
<Building2 size={16} style={{ color: 'var(--color-brand, #662582)' }} />
|
||||
<span
|
||||
style={{
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
color: 'var(--color-ink-1, #0f172a)',
|
||||
}}
|
||||
>
|
||||
Business Details
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="form-grid">
|
||||
<TextInput
|
||||
label={<span>Merchant name <span style={{ color: 'var(--color-error)' }}>*</span></span> as any}
|
||||
label={<span>Merchant name <span style={{ color: 'var(--color-error, #d64545)' }}>*</span></span> as any}
|
||||
value={form.tenantname}
|
||||
onChange={set('tenantname')}
|
||||
placeholder="e.g. Kaveri Groceries"
|
||||
/>
|
||||
<TextInput
|
||||
label={<span>Company registered name <span style={{ color: 'var(--color-error)' }}>*</span></span> as any}
|
||||
label={<span>Company registered name <span style={{ color: 'var(--color-error, #d64545)' }}>*</span></span> as any}
|
||||
value={form.companyname}
|
||||
onChange={set('companyname')}
|
||||
placeholder="e.g. Kaveri Retail Pvt. Ltd."
|
||||
/>
|
||||
{/* Asked here because here is the only place it can be asked.
|
||||
The primary outlet and the merchant's own admin login are
|
||||
both created inside this one call, and the login is copied
|
||||
from the tenant row — so the name given here names the
|
||||
business AND the account that signs in. Left out, both are
|
||||
blank, which is the state every merchant is in today. */}
|
||||
<TextInput
|
||||
label={<span>Store admin <span style={{ color: 'var(--color-error)' }}>*</span></span> as any}
|
||||
label={<span>Store admin <span style={{ color: 'var(--color-error, #d64545)' }}>*</span></span> as any}
|
||||
value={form.adminname}
|
||||
onChange={set('adminname')}
|
||||
placeholder="e.g. Ravi Kumar"
|
||||
description="The person who administers this shop. Their name goes on the store profile and on the admin login created with it — the account is provisioned as Admin, which is what this field is named after."
|
||||
/>
|
||||
<TextInput
|
||||
label={<span>Primary phone <span style={{ color: 'var(--color-error)' }}>*</span></span> as any}
|
||||
label={<span>Primary phone <span style={{ color: 'var(--color-error, #d64545)' }}>*</span></span> as any}
|
||||
value={form.primarycontact}
|
||||
onChange={set('primarycontact')}
|
||||
placeholder="9876543210"
|
||||
/>
|
||||
<TextInput
|
||||
label={<span>Primary admin email <span style={{ color: 'var(--color-error)' }}>*</span></span> as any}
|
||||
label={<span>Primary admin email <span style={{ color: 'var(--color-error, #d64545)' }}>*</span></span> as any}
|
||||
type="email"
|
||||
value={form.primaryemail}
|
||||
onChange={set('primaryemail')}
|
||||
placeholder="admin@kaveri.com"
|
||||
/>
|
||||
<TextInput
|
||||
label={<span>First outlet name <span style={{ color: 'var(--color-error)' }}>*</span></span> as any}
|
||||
label={<span>First outlet name <span style={{ color: 'var(--color-error, #d64545)' }}>*</span></span> as any}
|
||||
value={form.locationname}
|
||||
onChange={set('locationname')}
|
||||
placeholder="e.g. Kaveri RS Puram"
|
||||
@@ -258,6 +308,7 @@ export function OnboardTenantPage() {
|
||||
value: String(entry.categoryid),
|
||||
label: entry.categoryname,
|
||||
}))}
|
||||
placeholder="Select category"
|
||||
isDisabled={categories.isLoading}
|
||||
value={form.categoryid}
|
||||
onChange={set('categoryid')}
|
||||
@@ -266,24 +317,58 @@ export function OnboardTenantPage() {
|
||||
</VStack>
|
||||
</Card>
|
||||
|
||||
{/* Section 2: Head Office Card */}
|
||||
<Card padding={0} elevation="low">
|
||||
<VStack gap={2} padding={3}>
|
||||
<SectionHeader
|
||||
title="Head office"
|
||||
action={<MapPin size={17} style={{ color: 'var(--color-slate-400)' }} />}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
paddingBottom: 10,
|
||||
borderBottom: '1px solid var(--color-line, #e0e4ea)',
|
||||
}}
|
||||
>
|
||||
<MapPin size={16} style={{ color: 'var(--color-brand, #662582)' }} />
|
||||
<span
|
||||
style={{
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
color: 'var(--color-ink-1, #0f172a)',
|
||||
}}
|
||||
>
|
||||
Head Office
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<TextInput
|
||||
label={<span>Street address <span style={{ color: 'var(--color-error)' }}>*</span></span> as any}
|
||||
label={<span>Street address <span style={{ color: 'var(--color-error, #d64545)' }}>*</span></span> as any}
|
||||
value={form.address}
|
||||
onChange={set('address')}
|
||||
placeholder="e.g. 12, Avinashi Road"
|
||||
/>
|
||||
|
||||
<div className="form-grid-4">
|
||||
<TextInput label="Suburb" value={form.suburb} onChange={set('suburb')} placeholder="e.g. Peelamedu" />
|
||||
<TextInput label={<span>City <span style={{ color: 'var(--color-error)' }}>*</span></span> as any} value={form.city} onChange={set('city')} />
|
||||
<TextInput label={<span>State <span style={{ color: 'var(--color-error)' }}>*</span></span> as any} value={form.state} onChange={set('state')} />
|
||||
<TextInput
|
||||
label={<span>Postcode <span style={{ color: 'var(--color-error)' }}>*</span></span> as any}
|
||||
label="Suburb"
|
||||
value={form.suburb}
|
||||
onChange={set('suburb')}
|
||||
placeholder="e.g. Peelamedu"
|
||||
/>
|
||||
<TextInput
|
||||
label={<span>City <span style={{ color: 'var(--color-error, #d64545)' }}>*</span></span> as any}
|
||||
value={form.city}
|
||||
onChange={set('city')}
|
||||
placeholder="e.g. Coimbatore"
|
||||
/>
|
||||
<TextInput
|
||||
label={<span>State <span style={{ color: 'var(--color-error, #d64545)' }}>*</span></span> as any}
|
||||
value={form.state}
|
||||
onChange={set('state')}
|
||||
placeholder="e.g. Tamil Nadu"
|
||||
/>
|
||||
<TextInput
|
||||
label={<span>Postcode <span style={{ color: 'var(--color-error, #d64545)' }}>*</span></span> as any}
|
||||
value={form.postcode}
|
||||
onChange={set('postcode')}
|
||||
placeholder="641004"
|
||||
@@ -292,34 +377,60 @@ export function OnboardTenantPage() {
|
||||
</VStack>
|
||||
</Card>
|
||||
|
||||
{/* Error Alert */}
|
||||
{error ? (
|
||||
<HStack
|
||||
align="center"
|
||||
gap={1}
|
||||
padding={2}
|
||||
padding={1.5}
|
||||
style={{
|
||||
background: 'var(--color-error-muted, #fceeee)',
|
||||
borderRadius: 12,
|
||||
borderRadius: 8,
|
||||
color: 'var(--color-error, #d64545)',
|
||||
border: '1px solid var(--color-error-border, #fad1d1)',
|
||||
}}
|
||||
>
|
||||
<AlertCircle size={17} />
|
||||
<AlertCircle size={15} />
|
||||
<Text type="body" size="sm" style={{ color: 'inherit' }}>
|
||||
{error}
|
||||
</Text>
|
||||
</HStack>
|
||||
) : null}
|
||||
|
||||
<HStack justify="between" align="center" gap={2} wrap="wrap">
|
||||
{/* Sticky Bottom Actions */}
|
||||
<div
|
||||
style={{
|
||||
position: 'sticky',
|
||||
bottom: 0,
|
||||
zIndex: 20,
|
||||
padding: '12px 18px',
|
||||
background: 'rgba(255, 255, 255, 0.94)',
|
||||
backdropFilter: 'blur(8px)',
|
||||
border: '1px solid var(--color-line, #e0e4ea)',
|
||||
borderRadius: 10,
|
||||
boxShadow: '0 4px 16px -2px rgba(15, 23, 42, 0.08)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 12,
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
label="Cancel"
|
||||
onClick={() => navigate('/nearle/stores')}
|
||||
/>
|
||||
|
||||
<Button
|
||||
label={mutation.isPending ? 'Provisioning…' : 'Provision tenant'}
|
||||
type="submit"
|
||||
variant="primary"
|
||||
size="lg"
|
||||
label={mutation.isPending ? 'Saving…' : 'Save & Continue'}
|
||||
isLoading={mutation.isPending}
|
||||
isDisabled={!isComplete}
|
||||
/>
|
||||
</HStack>
|
||||
</div>
|
||||
</VStack>
|
||||
</form>
|
||||
</PageBody>
|
||||
|
||||
@@ -1,29 +1,7 @@
|
||||
/**
|
||||
* Rider partners — the companies that supply riders.
|
||||
*
|
||||
* ── Why this page did not exist ─────────────────────────────────────────────
|
||||
*
|
||||
* `getpartners` has always been readable and nothing on the platform could
|
||||
* create a partner, so the five that exist were inserted by hand — two are
|
||||
* still called "Test". Meanwhile 125 of 200 merchants already carry a
|
||||
* `partnerid`, and one partner supplies 48 shops while another supplies 63. The
|
||||
* relationship the whole delivery side rests on was real, live and unmanaged.
|
||||
*
|
||||
* ── What onboarding a partner records ───────────────────────────────────────
|
||||
*
|
||||
* Three things, and the last two are why the assign screen works at all:
|
||||
*
|
||||
* the district `partnerinfo.applocationid`, and `partnerlocations` beside
|
||||
* it. Every rider query joins through that id, so it has to be
|
||||
* a district Nearle actually services — see
|
||||
* `tamilNaduDistricts.ts` for why all 38 are shown anyway.
|
||||
* the merchant `tenants.partnerid`. This is what the assign screen reads to
|
||||
* decide whether to offer a partner tab at all.
|
||||
* the branch `tenantlocations.partnerid`. Which outlet they cover.
|
||||
*
|
||||
* A partner can also be attached to a merchant afterwards from that merchant's
|
||||
* own page — see `StoreDetailPage` — which is the ordinary case of a shop
|
||||
* changing partner without anybody re-onboarding the company.
|
||||
* Clean, modern SaaS management workspace for delivery partners, fleet tracking, and region coverage.
|
||||
*/
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
@@ -36,7 +14,12 @@ import { Table, type TableColumn } from '@astryxdesign/core/Table';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { TextInput } from '@astryxdesign/core/TextInput';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { Bike, Plus, Truck } from 'lucide-react';
|
||||
import {
|
||||
Bike,
|
||||
MapPin,
|
||||
Plus,
|
||||
Truck,
|
||||
} from 'lucide-react';
|
||||
import { errorMessage } from '@/api/client';
|
||||
import { partnersApi, type NewPartner, type Partner } from '@/api/deliveries';
|
||||
import { tenantsApi } from '@/api/tenants';
|
||||
@@ -70,15 +53,16 @@ interface PartnerRow extends Record<string, unknown> {
|
||||
riders: number | null;
|
||||
}
|
||||
|
||||
type StatusFilter = 'all' | 'active' | 'inactive';
|
||||
|
||||
export function PartnersPage() {
|
||||
const partners = useAllPartners();
|
||||
const regions = useAppRegions();
|
||||
const [editing, setEditing] = useState<Partner | 'new' | null>(null);
|
||||
/** The partner whose riders are on screen, if any. */
|
||||
const [ridersFor, setRidersFor] = useState<Partner | null>(null);
|
||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all');
|
||||
|
||||
/* The fleet size per partner, read alongside the directory. Without it the
|
||||
Riders button is a door with nothing written on it. */
|
||||
/* The fleet size per partner, read alongside the directory. */
|
||||
const riderCounts = usePartnerRiderCounts(partners.data.map((entry) => entry.partnerid));
|
||||
|
||||
const regionName = useMemo(() => {
|
||||
@@ -89,7 +73,7 @@ export function PartnersPage() {
|
||||
return map;
|
||||
}, [regions.data]);
|
||||
|
||||
const rows = useMemo<PartnerRow[]>(
|
||||
const rawRows = useMemo<PartnerRow[]>(
|
||||
() =>
|
||||
partners.data.map((partner) => ({
|
||||
partnerid: partner.partnerid,
|
||||
@@ -103,34 +87,81 @@ export function PartnersPage() {
|
||||
[partners.data, regionName, riderCounts],
|
||||
);
|
||||
|
||||
const paged = usePaged(rows);
|
||||
// Filtered rows based on status filter
|
||||
const filteredRows = useMemo(() => {
|
||||
if (statusFilter === 'all') return rawRows;
|
||||
return rawRows.filter((row) =>
|
||||
statusFilter === 'active'
|
||||
? row.status.toLowerCase() === 'active'
|
||||
: row.status.toLowerCase() !== 'active',
|
||||
);
|
||||
}, [rawRows, statusFilter]);
|
||||
|
||||
const paged = usePaged(filteredRows);
|
||||
|
||||
// KPI Calculations
|
||||
const totals = useMemo(() => {
|
||||
const totalPartners = rawRows.length;
|
||||
const activePartners = rawRows.filter((r) => r.status.toLowerCase() === 'active').length;
|
||||
const totalRiders = rawRows.reduce((sum, r) => sum + (r.riders ?? 0), 0);
|
||||
const uniqueDistricts = new Set(
|
||||
rawRows.map((r) => r.region).filter((reg) => reg && reg !== '—'),
|
||||
).size;
|
||||
|
||||
return { totalPartners, activePartners, totalRiders, uniqueDistricts };
|
||||
}, [rawRows]);
|
||||
|
||||
const columns: TableColumn<PartnerRow>[] = [
|
||||
{
|
||||
key: 'partnername',
|
||||
header: 'Partner',
|
||||
width: { type: 'proportional', value: 3 },
|
||||
renderCell: (row) => (
|
||||
<VStack gap={0}>
|
||||
<Text type="label" size="sm" weight="semibold">
|
||||
{row.partnername}
|
||||
</Text>
|
||||
{row.companyname ? (
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
{row.companyname}
|
||||
</Text>
|
||||
) : null}
|
||||
</VStack>
|
||||
),
|
||||
width: { type: 'proportional', value: 3.5 },
|
||||
renderCell: (row) => {
|
||||
const initial = (row.partnername || 'P')[0]?.toUpperCase() ?? 'P';
|
||||
return (
|
||||
<HStack align="center" gap={1.5}>
|
||||
<div
|
||||
style={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 8,
|
||||
background: 'var(--color-brand-tint, #f4eef8)',
|
||||
color: 'var(--color-brand, #662582)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontWeight: 700,
|
||||
fontSize: 13,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{initial}
|
||||
</div>
|
||||
<VStack gap={0}>
|
||||
<Text type="label" size="sm" weight="semibold">
|
||||
{row.partnername}
|
||||
</Text>
|
||||
{row.companyname ? (
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
{row.companyname}
|
||||
</Text>
|
||||
) : null}
|
||||
</VStack>
|
||||
</HStack>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'region',
|
||||
header: 'Home region',
|
||||
width: { type: 'proportional', value: 2 },
|
||||
renderCell: (row) => (
|
||||
<Text type="body" size="sm" color="secondary">
|
||||
{row.region}
|
||||
</Text>
|
||||
<HStack align="center" gap={0.5}>
|
||||
<MapPin size={13} style={{ color: 'var(--color-ink-4, #97a1b0)' }} />
|
||||
<Text type="body" size="sm" color="secondary">
|
||||
{row.region}
|
||||
</Text>
|
||||
</HStack>
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -156,21 +187,13 @@ export function PartnersPage() {
|
||||
),
|
||||
},
|
||||
{
|
||||
/* Both actions in ONE column with a header, rather than two unlabelled
|
||||
ones. The riders button carries the fleet size, because "Riders" alone
|
||||
asks you to open a drawer to learn whether there are any — and the
|
||||
answer is the reason you would open it. */
|
||||
key: 'actions',
|
||||
header: 'Fleet',
|
||||
align: 'end',
|
||||
width: { type: 'pixel', value: 184 },
|
||||
width: { type: 'pixel', value: 190 },
|
||||
renderCell: (row) => (
|
||||
<HStack gap={0.5} justify="end" align="center" className="fleet-actions">
|
||||
<HStack gap={0.5} justify="end" align="center">
|
||||
<Button
|
||||
/* One width for every row, fixed in CSS. The label is a count, so
|
||||
it runs from one digit to three and a shrink-to-fit button leaves
|
||||
the column ragged — an uneven edge reads as disorder before the
|
||||
numbers themselves are read. */
|
||||
label={row.riders === null ? '—' : `${row.riders} rider${row.riders === 1 ? '' : 's'}`}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
@@ -198,6 +221,29 @@ export function PartnersPage() {
|
||||
<VStack gap={3}>
|
||||
<PageHeader
|
||||
title="Rider partners"
|
||||
isTabsInline
|
||||
tabs={
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<FilterTab
|
||||
label="All partners"
|
||||
count={rawRows.length}
|
||||
isActive={statusFilter === 'all'}
|
||||
onClick={() => setStatusFilter('all')}
|
||||
/>
|
||||
<FilterTab
|
||||
label="Active"
|
||||
count={totals.activePartners}
|
||||
isActive={statusFilter === 'active'}
|
||||
onClick={() => setStatusFilter('active')}
|
||||
/>
|
||||
<FilterTab
|
||||
label="Inactive"
|
||||
count={rawRows.length - totals.activePartners}
|
||||
isActive={statusFilter === 'inactive'}
|
||||
onClick={() => setStatusFilter('inactive')}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
actions={
|
||||
<Button
|
||||
label="Onboard rider partner"
|
||||
@@ -209,33 +255,30 @@ export function PartnersPage() {
|
||||
}
|
||||
/>
|
||||
|
||||
<VStack gap={1.5}>
|
||||
{/* No section heading. The page is already titled "Rider partners" and
|
||||
the table is the only thing on it — a second heading over one table
|
||||
restates the page and pushes the rows down a row for nothing. */}
|
||||
<Card padding={0} elevation="low">
|
||||
<DataState
|
||||
isLoading={partners.isLoading}
|
||||
error={null}
|
||||
isEmpty={rows.length === 0}
|
||||
emptyTitle="No rider partners yet"
|
||||
emptyDescription="A rider partner is the company that supplies riders. Onboard one, choose its district, and attach it to the shop and branch it delivers for."
|
||||
>
|
||||
<div className="table-scroll">
|
||||
<Table<PartnerRow>
|
||||
data={paged.rows}
|
||||
columns={columns}
|
||||
idKey="partnerid"
|
||||
density="balanced"
|
||||
hasHover
|
||||
dividers="rows"
|
||||
/>
|
||||
</div>
|
||||
<TablePager paged={paged} label="partners" />
|
||||
</DataState>
|
||||
</Card>
|
||||
</VStack>
|
||||
{/* Partners Table Card */}
|
||||
<Card padding={0} elevation="low" style={{ background: '#ffffff', borderRadius: 12 }}>
|
||||
<DataState
|
||||
isLoading={partners.isLoading}
|
||||
error={null}
|
||||
isEmpty={filteredRows.length === 0}
|
||||
emptyTitle="No rider partners yet"
|
||||
emptyDescription="A rider partner is the company that supplies delivery riders. Click Onboard rider partner to add your first partner."
|
||||
>
|
||||
<div className="table-scroll">
|
||||
<Table<PartnerRow>
|
||||
data={paged.rows}
|
||||
columns={columns}
|
||||
idKey="partnerid"
|
||||
density="balanced"
|
||||
hasHover
|
||||
dividers="rows"
|
||||
/>
|
||||
</div>
|
||||
<TablePager paged={paged} label="partners" />
|
||||
</DataState>
|
||||
</Card>
|
||||
|
||||
{/* Drawers */}
|
||||
{ridersFor ? (
|
||||
<PartnerRidersDrawer partner={ridersFor} onClose={() => setRidersFor(null)} />
|
||||
) : null}
|
||||
@@ -250,6 +293,54 @@ export function PartnersPage() {
|
||||
);
|
||||
}
|
||||
|
||||
/** Filter pill button */
|
||||
function FilterTab({
|
||||
label,
|
||||
count,
|
||||
isActive,
|
||||
onClick,
|
||||
}: {
|
||||
label: string;
|
||||
count: number;
|
||||
isActive: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
padding: '5px 12px',
|
||||
borderRadius: 999,
|
||||
border: `1px solid ${isActive ? 'var(--color-brand, #662582)' : 'var(--color-line, #e0e4ea)'}`,
|
||||
background: isActive ? 'var(--color-brand-tint, #f4eef8)' : '#ffffff',
|
||||
color: isActive ? 'var(--color-brand, #662582)' : 'var(--color-ink-2, #414b5a)',
|
||||
fontSize: 12.5,
|
||||
fontWeight: isActive ? 600 : 500,
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s ease',
|
||||
}}
|
||||
>
|
||||
<span>{label}</span>
|
||||
<span
|
||||
style={{
|
||||
padding: '1px 6px',
|
||||
borderRadius: 999,
|
||||
background: isActive ? 'var(--color-brand, #662582)' : 'var(--color-surface-sunken, #f2f4f7)',
|
||||
color: isActive ? '#ffffff' : 'var(--color-ink-3, #657081)',
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{count}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── The form ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
interface FormState {
|
||||
@@ -263,11 +354,8 @@ interface FormState {
|
||||
city: string;
|
||||
state: string;
|
||||
postcode: string;
|
||||
/** The serviced district they work out of — an `app_location` id. */
|
||||
applocationid: number;
|
||||
/** The merchant this partner delivers for. */
|
||||
tenantid: number;
|
||||
/** Which branch of that merchant — written to `tenantlocations.partnerid`. */
|
||||
locationid: number;
|
||||
}
|
||||
|
||||
@@ -320,10 +408,6 @@ function PartnerDrawer({ partner, onClose }: { partner: Partner | null; onClose:
|
||||
};
|
||||
}
|
||||
|
||||
/* ── The district ─────────────────────────────────────────────────────────
|
||||
One per partner, chosen from all 38. Picking one Nearle does not run yet
|
||||
opens it — the partner is sent with the NAME and the server writes the
|
||||
`app_location` and `app_locationconfig` rows first. */
|
||||
const [districtSearch, setDistrictSearch] = useState('');
|
||||
const [district, setDistrict] = useState<string>(() => partner?.city ?? '');
|
||||
const districts = useMemo(() => districtOptions(regions.data ?? []), [regions.data]);
|
||||
@@ -333,17 +417,6 @@ function PartnerDrawer({ partner, onClose }: { partner: Partner | null; onClose:
|
||||
);
|
||||
const chosenDistrict = districts.find((option) => option.name === district);
|
||||
|
||||
/* ── Who they deliver for ─────────────────────────────────────────────────
|
||||
A partner supplies riders TO a merchant's branch. Both links are written:
|
||||
`tenants.partnerid`, which is what the assign screen reads to decide
|
||||
whether to offer a partner tab at all, and `tenantlocations.partnerid`,
|
||||
which records the branch. Without the first the toggle never appears;
|
||||
without the second nothing says which outlet they cover.
|
||||
|
||||
Filtered to the district: a partner works one district, so a merchant in
|
||||
another is not somebody they can deliver for. `getalltenants` returns a row
|
||||
per BRANCH, and a branch's city is what places it — the tenant's own city
|
||||
is the head office and can differ. */
|
||||
const merchants = useTenants({ pageno: 1, pagesize: 200 });
|
||||
const merchantOptions = useMemo(() => {
|
||||
const here = district.trim().toLowerCase();
|
||||
@@ -380,11 +453,6 @@ function PartnerDrawer({ partner, onClose }: { partner: Partner | null; onClose:
|
||||
city: form.city.trim(),
|
||||
state: form.state.trim(),
|
||||
...(form.postcode.trim() ? { postcode: Number(form.postcode) || 0 } : {}),
|
||||
/*
|
||||
The district, by id when Nearle already runs it and by NAME when it
|
||||
does not. The name is what opens it — the server writes the region
|
||||
rows before the partner, so all 38 are real choices rather than three.
|
||||
*/
|
||||
applocationid: chosenDistrict?.applocationid ?? 0,
|
||||
...(chosenDistrict && chosenDistrict.applocationid === 0
|
||||
? { district: chosenDistrict.name }
|
||||
@@ -394,15 +462,6 @@ function PartnerDrawer({ partner, onClose }: { partner: Partner | null; onClose:
|
||||
? partnersApi.update({ ...body, partnerid: partner.partnerid }).then(() => partner.partnerid)
|
||||
: partnersApi.create(body).then((result) => result?.partnerid ?? 0);
|
||||
},
|
||||
/*
|
||||
The placement is written after the partner exists, because it needs the
|
||||
id the create hands back.
|
||||
|
||||
Reported separately if it fails, and deliberately not rolled back: the
|
||||
partner is real either way and re-onboarding them would refuse on the
|
||||
duplicate contact number. Saying "the partner was created but could not be
|
||||
placed" is recoverable — the drawer stays open on the same form.
|
||||
*/
|
||||
onSuccess: async (partnerid) => {
|
||||
if (partnerid > 0 && form.tenantid > 0) {
|
||||
try {
|
||||
@@ -490,13 +549,6 @@ function PartnerDrawer({ partner, onClose }: { partner: Partner | null; onClose:
|
||||
onChange={set('registrationno')}
|
||||
/>
|
||||
|
||||
{/* ── District ──────────────────────────────────────────────────────
|
||||
All 38 of Tamil Nadu's districts, searchable, with only the ones
|
||||
Nearle services selectable. A partner placed in a district that has
|
||||
no `app_location` row is a partner whose riders no query returns —
|
||||
`getriders` filters on that id — so an unserviced district is shown
|
||||
and refused rather than hidden, because "Erode is not open yet" is
|
||||
an answer and a missing Erode is not. */}
|
||||
<VStack gap={1}>
|
||||
<Text type="label" size="sm" weight="semibold">
|
||||
District
|
||||
@@ -510,10 +562,6 @@ function PartnerDrawer({ partner, onClose }: { partner: Partner | null; onClose:
|
||||
placeholder="Search all 38 districts…"
|
||||
hasClear
|
||||
/>
|
||||
{/* A single-select list, not a cloud of chips: one partner works one
|
||||
district, so this is a choice with one answer and it should read
|
||||
like one. Running districts carry a tick, new ones say what will
|
||||
happen — the difference is operational, not a restriction. */}
|
||||
<div className="district-list" role="listbox" aria-label="Tamil Nadu districts">
|
||||
{shown.map((option) => {
|
||||
const running = isRunning(option);
|
||||
@@ -528,9 +576,6 @@ function PartnerDrawer({ partner, onClose }: { partner: Partner | null; onClose:
|
||||
data-chosen={chosen ? 'yes' : 'no'}
|
||||
onClick={() => {
|
||||
setDistrict(option.name);
|
||||
// The merchant is district-scoped, so changing the district
|
||||
// invalidates it — keeping it would attach a partner to a
|
||||
// shop in a place they do not work.
|
||||
setForm((prev) => ({ ...prev, tenantid: 0, locationid: 0 }));
|
||||
}}
|
||||
>
|
||||
@@ -556,10 +601,6 @@ function PartnerDrawer({ partner, onClose }: { partner: Partner | null; onClose:
|
||||
</Text>
|
||||
</VStack>
|
||||
|
||||
{/* ── Who they deliver for ──────────────────────────────────────────
|
||||
The merchant, then the branch. Both links are written: the merchant
|
||||
one is what the assign screen reads to decide whether to offer a
|
||||
partner tab at all, and the branch one records which outlet. */}
|
||||
<VStack gap={1}>
|
||||
<Text type="label" size="sm" weight="semibold">
|
||||
Delivers for
|
||||
@@ -569,8 +610,6 @@ function PartnerDrawer({ partner, onClose }: { partner: Partner | null; onClose:
|
||||
size="sm"
|
||||
value={form.tenantid ? String(form.tenantid) : ''}
|
||||
onChange={(value) => {
|
||||
// A new merchant clears the branch with it — keeping it would
|
||||
// leave another shop's outlet id attached to this partner.
|
||||
setForm((prev) => ({ ...prev, tenantid: Number(value) || 0, locationid: 0 }));
|
||||
}}
|
||||
options={merchantOptions}
|
||||
|
||||
261
src/features/store-admin/BranchScope.dom.test.tsx
Normal file
261
src/features/store-admin/BranchScope.dom.test.tsx
Normal file
@@ -0,0 +1,261 @@
|
||||
/**
|
||||
* The branch filter, mounted for real.
|
||||
*
|
||||
* ── Why this test exists ────────────────────────────────────────────────────
|
||||
*
|
||||
* The filter silently reset to "All branches" on every navigation, and nothing
|
||||
* in the codebase could see it. The selection was derived straight from the
|
||||
* `?branch=` search param, and every nav link in `AppShell` is a bare path
|
||||
* (`to="/admin/sales"`), so React Router replaced the whole location and the
|
||||
* param went with it. An absent param read as All.
|
||||
*
|
||||
* No pure-function test could catch that: the bug only exists in the
|
||||
* interaction between the provider, the router and a link that drops the query
|
||||
* string. So this mounts the provider inside a real router, navigates the way
|
||||
* the shell does, and asserts the selection survives.
|
||||
*
|
||||
* The assertions below are mostly about what must NOT change: a nav click must
|
||||
* not widen the operator's scope, and the URL must still be able to set it, or
|
||||
* a shared link to one shop stops working.
|
||||
*/
|
||||
import assert from 'node:assert/strict';
|
||||
import { after, before, test } from 'node:test';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import type { TenantLocation } from '@/api/types';
|
||||
|
||||
const BRANCHES = [
|
||||
{ locationid: 1097, tenantid: 1087, locationname: 'Ragul stores', status: 'Active' },
|
||||
{ locationid: 1135, tenantid: 1087, locationname: 'Ragul Selvapuram', status: 'Active' },
|
||||
{ locationid: 1138, tenantid: 1087, locationname: 'Deborah Lara', status: 'Active' },
|
||||
] as TenantLocation[];
|
||||
|
||||
interface Harness {
|
||||
/** What the provider currently reports. */
|
||||
read: () => { selected: number | null; scopedCount: number; search: string; branchCount: number; isLoading: boolean };
|
||||
/** Pick a branch through the provider's own `select`. */
|
||||
pick: (next: number | null) => Promise<void>;
|
||||
/** Navigate the way `AppShell` does — a bare path, no query string. */
|
||||
navigate: (to: string) => Promise<void>;
|
||||
}
|
||||
|
||||
let mount: (opts: { url: string; pin?: number }) => Promise<Harness>;
|
||||
|
||||
/* Everything a mount creates, so it can be torn down. Without this the file
|
||||
never exits: each mount leaves a live QueryClient with an active observer,
|
||||
and node:test waits on the open handles until the suite times out — every
|
||||
test passing and the FILE reported as failed. */
|
||||
const created: { unmount: () => void }[] = [];
|
||||
let closeDom: () => void = () => {};
|
||||
|
||||
before(async () => {
|
||||
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', {
|
||||
url: 'http://localhost/admin/console',
|
||||
pretendToBeVisual: true,
|
||||
});
|
||||
const win = dom.window as unknown as Record<string, unknown>;
|
||||
const g = globalThis as Record<string, unknown>;
|
||||
for (const key of [
|
||||
'window', 'document', 'HTMLElement', 'Element', 'Node', 'SVGElement', 'Event',
|
||||
'getComputedStyle', 'requestAnimationFrame', 'cancelAnimationFrame',
|
||||
'localStorage', 'sessionStorage', 'MouseEvent', 'CustomEvent',
|
||||
]) g[key] = win[key];
|
||||
Object.defineProperty(globalThis, 'navigator', { value: win['navigator'], configurable: true });
|
||||
g['ResizeObserver'] = class { observe() {} unobserve() {} disconnect() {} };
|
||||
|
||||
/* No network. Without this the test is not hermetic and it silently was not:
|
||||
`useTenantLocations` fetched the REAL tenant 1087 from fiesta.nearle.app and
|
||||
replaced the three seeded branches with the six that shop actually has, so
|
||||
two assertions failed against production data that has nothing to do with
|
||||
what is under test — and would fail differently the day someone opens a
|
||||
seventh outlet. */
|
||||
g['fetch'] = () => Promise.reject(new Error('no network in tests'));
|
||||
|
||||
const React = await import('react');
|
||||
const { createRoot } = await import('react-dom/client');
|
||||
const { MemoryRouter, Routes, Route, useNavigate, useLocation } = await import('react-router-dom');
|
||||
const { BranchScopeProvider, useBranchScope } = await import('./BranchScope');
|
||||
|
||||
/* The branch list, without the network. `useTenantLocations` is a TanStack
|
||||
hook; stubbing the module would mean stubbing the query client too, so the
|
||||
provider is given a real one whose fetch resolves immediately. */
|
||||
const { QueryClient, QueryClientProvider } = await import('@tanstack/react-query');
|
||||
const { AuthContext } = await import('@/auth/context');
|
||||
|
||||
closeDom = () => dom.window.close();
|
||||
|
||||
mount = async ({ url, pin }) => {
|
||||
const host = dom.window.document.createElement('div');
|
||||
dom.window.document.body.appendChild(host);
|
||||
|
||||
const qc = new QueryClient({
|
||||
// No gcTime: 0 here. It collects the seeded entry before any component has
|
||||
// subscribed to it, so the provider saw an empty branch list and every
|
||||
// assertion read null.
|
||||
defaultOptions: { queries: { retry: false, staleTime: Infinity } },
|
||||
});
|
||||
// Seed the cache under the key `useTenantLocations` reads, so the provider
|
||||
// sees a loaded branch list on first render.
|
||||
const { queryKeys } = await import('@/queries/keys');
|
||||
qc.setQueryData(queryKeys.tenants.locations(1087), BRANCHES);
|
||||
|
||||
let api: { selected: number | null; scopedCount: number; search: string; branchCount: number; isLoading: boolean } | null = null;
|
||||
let doSelect: ((n: number | null) => void) | null = null;
|
||||
let doNavigate: ((to: string) => void) | null = null;
|
||||
|
||||
function Probe() {
|
||||
const scope = useBranchScope();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
api = {
|
||||
selected: scope.selected,
|
||||
scopedCount: scope.scoped.length,
|
||||
search: location.search,
|
||||
branchCount: scope.branches.length,
|
||||
isLoading: scope.isLoading,
|
||||
};
|
||||
doSelect = scope.select;
|
||||
doNavigate = (to) => navigate(to);
|
||||
return null;
|
||||
}
|
||||
|
||||
const auth = {
|
||||
user: {
|
||||
userid: 1, role: 'store-admin', name: 'A', email: 'a@b.c',
|
||||
roleid: 1, tenantid: 1087, locationid: 0, issuperadmin: false,
|
||||
},
|
||||
isLoading: false,
|
||||
signIn: async () => { throw new Error('not used'); },
|
||||
signOut: () => {},
|
||||
};
|
||||
|
||||
const root = createRoot(host);
|
||||
created.push({ unmount: () => { root.unmount(); qc.unmount(); qc.clear(); } });
|
||||
root.render(
|
||||
React.createElement(
|
||||
QueryClientProvider, { client: qc },
|
||||
React.createElement(
|
||||
AuthContext.Provider, { value: auth as never },
|
||||
React.createElement(
|
||||
MemoryRouter, { initialEntries: [url] },
|
||||
React.createElement(
|
||||
Routes, null,
|
||||
React.createElement(Route, {
|
||||
path: '/admin/*',
|
||||
// children passed in the props object, not as a third argument:
|
||||
// BranchScopeProvider declares children as required, and
|
||||
// createElement's overload will not accept a null props object.
|
||||
element: React.createElement(BranchScopeProvider, {
|
||||
pin,
|
||||
children: React.createElement(Probe),
|
||||
}),
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const settle = () => new Promise((r) => setTimeout(r, 60));
|
||||
await settle();
|
||||
|
||||
return {
|
||||
read: () => api!,
|
||||
pick: async (next) => { doSelect!(next); await settle(); },
|
||||
navigate: async (to) => { doNavigate!(to); await settle(); },
|
||||
};
|
||||
};
|
||||
});
|
||||
|
||||
/* ── The bug ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
// THE regression test. A nav click replaces the whole location, query string
|
||||
// included; that must not change which shop the operator is looking at.
|
||||
test('a nav click does not reset the chosen branch', async () => {
|
||||
const h = await mount({ url: '/admin/console' });
|
||||
await h.pick(1135);
|
||||
assert.equal(h.read().selected, 1135);
|
||||
|
||||
await h.navigate('/admin/sales');
|
||||
assert.equal(h.read().selected, 1135, 'navigating to Sales widened the scope back to All');
|
||||
|
||||
await h.navigate('/admin/inventory');
|
||||
assert.equal(h.read().selected, 1135, 'the reset appeared on the second hop');
|
||||
});
|
||||
|
||||
// Scope is what pages actually read, so it has to narrow with the selection —
|
||||
// a label that changes while every page keeps reading all six is the same bug
|
||||
// wearing a different hat.
|
||||
test('the scope pages read narrows to the one branch, and survives too', async () => {
|
||||
const h = await mount({ url: '/admin/console' });
|
||||
assert.equal(h.read().scopedCount, 3, 'All branches should scope to every outlet');
|
||||
|
||||
await h.pick(1138);
|
||||
assert.equal(h.read().scopedCount, 1);
|
||||
|
||||
await h.navigate('/admin/reports');
|
||||
assert.equal(h.read().scopedCount, 1, 'scope widened on navigation');
|
||||
});
|
||||
|
||||
// The URL is a mirror, and it has to be put back after a nav click dropped it,
|
||||
// or the address bar quietly disagrees with the control.
|
||||
test('the param is written back after navigation drops it', async () => {
|
||||
const h = await mount({ url: '/admin/console' });
|
||||
await h.pick(1135);
|
||||
assert.match(h.read().search, /branch=1135/);
|
||||
|
||||
await h.navigate('/admin/sales');
|
||||
assert.match(h.read().search, /branch=1135/, 'the URL lost the branch it is scoped to');
|
||||
});
|
||||
|
||||
/* ── What must keep working ──────────────────────────────────────────────── */
|
||||
|
||||
// The reason the param existed in the first place: a link to one shop's
|
||||
// inventory has to survive being pasted into a chat.
|
||||
test('an explicit param in the URL still sets the branch', async () => {
|
||||
const h = await mount({ url: '/admin/inventory?branch=1138' });
|
||||
assert.equal(h.read().selected, 1138);
|
||||
});
|
||||
|
||||
// The id is user-editable, so it is untrusted. An outlet this tenant does not
|
||||
// own falls back to All rather than rendering an empty page.
|
||||
test('an id this tenant does not own falls back to All', async () => {
|
||||
const h = await mount({ url: '/admin/console?branch=999999' });
|
||||
assert.equal(h.read().selected, null);
|
||||
assert.equal(h.read().scopedCount, 3);
|
||||
});
|
||||
|
||||
test('branch=all is honoured, and switching back to All works', async () => {
|
||||
const explicit = await mount({ url: '/admin/console?branch=all' });
|
||||
assert.equal(explicit.read().selected, null);
|
||||
|
||||
const h = await mount({ url: '/admin/console' });
|
||||
await h.pick(1097);
|
||||
assert.equal(h.read().selected, 1097);
|
||||
await h.pick(null);
|
||||
assert.equal(h.read().selected, null, 'could not get back to All branches');
|
||||
assert.doesNotMatch(h.read().search, /branch=/, 'the param should be removed under All');
|
||||
});
|
||||
|
||||
/* ── The pinned workspace ────────────────────────────────────────────────── */
|
||||
|
||||
// Fiesta authorises a POS or catalogue read on locationid alone, so for a store
|
||||
// user the URL param is not a filter — it is the authorisation boundary. The
|
||||
// pin has to win over anything in the address bar.
|
||||
test('a pinned branch ignores the URL and cannot be selected away from', async () => {
|
||||
const h = await mount({ url: '/admin/console?branch=1138', pin: 1097 });
|
||||
assert.equal(h.read().selected, 1097, 'the URL overrode the session pin');
|
||||
assert.equal(h.read().scopedCount, 1);
|
||||
|
||||
await h.pick(1135);
|
||||
assert.equal(h.read().selected, 1097, 'select() moved a pinned scope');
|
||||
|
||||
await h.navigate('/admin/sales');
|
||||
assert.equal(h.read().selected, 1097);
|
||||
});
|
||||
|
||||
/* ── Teardown ────────────────────────────────────────────────────────────── */
|
||||
|
||||
after(() => {
|
||||
for (const c of created) c.unmount();
|
||||
closeDom();
|
||||
});
|
||||
@@ -1,4 +1,11 @@
|
||||
import { createContext, useContext, useMemo, type ReactNode } from 'react';
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { useAuth } from '@/auth/AuthContext';
|
||||
import { useTenantLocations } from '@/queries/hooks';
|
||||
@@ -32,16 +39,18 @@ export interface BranchScopeValue {
|
||||
|
||||
const BranchScopeContext = createContext<BranchScopeValue | null>(null);
|
||||
|
||||
/** The URL param. In the URL so a link to a page carries its branch with it. */
|
||||
/** The URL param. Mirrors the selection so a link carries its branch with it. */
|
||||
const PARAM = 'branch';
|
||||
|
||||
/**
|
||||
* Which branch the Store Admin is looking at.
|
||||
*
|
||||
* Held in the URL rather than in component state for two reasons. A link to
|
||||
* "Inventory, Peelamedu" has to survive being pasted into a chat, and a reload
|
||||
* during a shift must not silently drop the operator back to All branches while
|
||||
* they are reading a number that only makes sense for one shop.
|
||||
* Held in component state, mirrored to the URL. The mirror is what lets a link
|
||||
* to "Inventory, Peelamedu" survive being pasted into a chat; holding the state
|
||||
* here rather than reading it back out of the address bar is what stops a nav
|
||||
* click — which replaces the query string — from resetting the operator to All
|
||||
* branches mid-shift while they read a number that only means anything for one
|
||||
* shop. See the long note on `chosen` below.
|
||||
*
|
||||
* The tenant, by contrast, comes from the session and is deliberately NOT in
|
||||
* the URL. Fiesta has no web auth, so tenant scoping is enforced by this client
|
||||
@@ -83,15 +92,68 @@ export function BranchScopeProvider({
|
||||
);
|
||||
|
||||
const raw = params.get(PARAM);
|
||||
const parsed = raw === null || raw === 'all' ? null : Number(raw);
|
||||
// An id in the URL that this tenant does not own falls back to All rather
|
||||
// than showing an empty page — the id is user-editable, so it is untrusted.
|
||||
const selected =
|
||||
pin !== undefined
|
||||
? pin
|
||||
: parsed !== null && Number.isFinite(parsed) && branches.some((b) => b.locationid === parsed)
|
||||
? parsed
|
||||
: null;
|
||||
|
||||
/*
|
||||
The selection lives here, and the URL only mirrors it.
|
||||
|
||||
── The bug this fixes ──────────────────────────────────────────────────────
|
||||
|
||||
It used to be derived straight from the search param, with an absent param
|
||||
meaning All branches. That is wrong, because absent does not mean "show me
|
||||
everything" — it mostly means "you just clicked a nav tab". Every link in
|
||||
`AppShell` is a bare path (`to="/admin/sales"`), so React Router replaces the
|
||||
whole location, query string included, and the param is simply gone. The
|
||||
branch filter therefore reset to All on every navigation: pick a shop on
|
||||
Console, click Sales, and you were back to all six with nothing saying so.
|
||||
Reproduced on tenant 1087 (Ragul Stores, 6 branches) — the label went from
|
||||
"Ragul stores Selvapuram" back to "All branches (6)".
|
||||
|
||||
Copying the whole search string onto the nav links would have fixed it and
|
||||
broken something else: `InventoryPage` and the global catalogue keep their own
|
||||
params, and those would then follow the operator from page to page.
|
||||
|
||||
── The rule ────────────────────────────────────────────────────────────────
|
||||
|
||||
A param that is PRESENT is obeyed, so a link to "Inventory, Peelamedu" still
|
||||
survives being pasted into a chat, and editing the id in the address bar still
|
||||
works. A param that is ABSENT changes nothing, so navigation cannot silently
|
||||
widen the operator's scope. The effect below then writes the param back, which
|
||||
is what keeps the URL honest after a nav click.
|
||||
*/
|
||||
const [chosen, setChosen] = useState<BranchSelection>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (pin !== undefined || raw === null) return;
|
||||
if (raw === 'all') {
|
||||
setChosen(null);
|
||||
return;
|
||||
}
|
||||
const parsed = Number(raw);
|
||||
if (Number.isFinite(parsed) && branches.some((b) => b.locationid === parsed)) {
|
||||
setChosen(parsed);
|
||||
} else if (!isLoading) {
|
||||
// An id this tenant does not own falls back to All rather than showing an
|
||||
// empty page — the id is user-editable, so it is untrusted. Guarded on
|
||||
// `isLoading` because `branches` is empty until the fetch lands, and
|
||||
// resetting then would throw away a perfectly good deep link.
|
||||
setChosen(null);
|
||||
}
|
||||
}, [raw, branches, isLoading, pin]);
|
||||
|
||||
const selected = pin !== undefined ? pin : chosen;
|
||||
|
||||
// The URL follows the selection, including putting the param back after a nav
|
||||
// click has dropped it. Built from the current params so a page's own query
|
||||
// state is carried through untouched.
|
||||
useEffect(() => {
|
||||
if (pin !== undefined) return;
|
||||
const want = selected === null ? null : String(selected);
|
||||
if ((params.get(PARAM) ?? null) === want) return;
|
||||
const next = new URLSearchParams(params);
|
||||
if (want === null) next.delete(PARAM);
|
||||
else next.set(PARAM, want);
|
||||
setParams(next, { replace: true });
|
||||
}, [selected, params, setParams, pin]);
|
||||
|
||||
const value = useMemo<BranchScopeValue>(() => {
|
||||
const current = selected === null ? undefined : branches.find((b) => b.locationid === selected);
|
||||
@@ -103,15 +165,14 @@ export function BranchScopeProvider({
|
||||
current,
|
||||
isPinned: pin !== undefined,
|
||||
scoped: current ? [current] : branches,
|
||||
// State only. The URL is updated by the mirroring effect above, so there
|
||||
// is one place that writes the param rather than two that can disagree.
|
||||
select: (next) => {
|
||||
if (pin !== undefined) return;
|
||||
const nextParams = new URLSearchParams(params);
|
||||
if (next === null) nextParams.delete(PARAM);
|
||||
else nextParams.set(PARAM, String(next));
|
||||
setParams(nextParams, { replace: true });
|
||||
setChosen(next);
|
||||
},
|
||||
};
|
||||
}, [branches, isLoading, tenantid, selected, params, setParams, pin]);
|
||||
}, [branches, isLoading, tenantid, selected, pin]);
|
||||
|
||||
return <BranchScopeContext.Provider value={value}>{children}</BranchScopeContext.Provider>;
|
||||
}
|
||||
|
||||
@@ -359,6 +359,40 @@ function reasonFor(row: CounterRow): string {
|
||||
return row.card?.problem.detail ?? 'Reporting late.';
|
||||
}
|
||||
|
||||
function lastSale(row: CounterRow): string {
|
||||
const at = row.status?.lastBillAt;
|
||||
if (!at) return '—';
|
||||
return `${shortAge(Date.now() - at.getTime())} ago`;
|
||||
}
|
||||
<HStack key={row.key} gap={3} align="center" justify="space-between">
|
||||
<VStack gap={0.5}>
|
||||
<Text type="label" size="sm" weight="semibold">{row.terminalId} <StateChip state={row.state} /></Text>
|
||||
<Text type="body" size="sm" color="secondary">{reasonFor(row)}</Text>
|
||||
</VStack>
|
||||
<Button label="View" size="sm" variant="secondary" onClick={() => onOpen(row)} />
|
||||
</HStack>
|
||||
))}
|
||||
</VStack>
|
||||
</VStack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/** What a person needs to know before walking over. */
|
||||
function reasonFor(row: CounterRow): string {
|
||||
if (row.state === 'never') return 'This counter has never recorded a sale.';
|
||||
if (row.state === 'offline') {
|
||||
return row.status?.silentForMs == null
|
||||
? 'No heartbeat received.'
|
||||
: `Last heard ${shortAge(row.status.silentForMs)} ago.`;
|
||||
}
|
||||
if (row.status && row.status.pendingBills > 0) {
|
||||
const n = row.status.pendingBills;
|
||||
return `${n} bill${n === 1 ? '' : 's'} taken here have not reached the books.`;
|
||||
}
|
||||
return row.card?.problem.detail ?? 'Reporting late.';
|
||||
}
|
||||
|
||||
function lastSale(row: CounterRow): string {
|
||||
const at = row.status?.lastBillAt;
|
||||
if (!at) return '—';
|
||||
|
||||
@@ -11,17 +11,16 @@ import { Selector } from '@astryxdesign/core/Selector';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { TextInput } from '@astryxdesign/core/TextInput';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { AlertCircle, CheckCircle2, Clock, MapPin, Store } from 'lucide-react';
|
||||
import { AlertCircle, ArrowLeft, CheckCircle2, Clock, MapPin, Store } from 'lucide-react';
|
||||
import { tenantsApi, type CreateBranchRequest } from '@/api/tenants';
|
||||
import { errorMessage } from '@/api/client';
|
||||
import { PageBody } from '@/components/PageBody';
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
import { SectionHeader } from '@/components/SectionHeader';
|
||||
import { queryKeys } from '@/queries/keys';
|
||||
import { StoreQrPanel } from '@/features/qr/StoreQrPanel';
|
||||
import { useStaff, useTenants } from '@/queries/hooks';
|
||||
import { useStaff, useTenantLocations, useTenants } from '@/queries/hooks';
|
||||
import type { TenantInfo } from '@/api/types';
|
||||
import { isUnplaced } from '../staffPlacement';
|
||||
import { useBranchScope } from '../BranchScope';
|
||||
|
||||
interface FormState {
|
||||
tenantid: string;
|
||||
@@ -41,11 +40,6 @@ interface FormState {
|
||||
operatorid: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* `createISOTimeString` returns null for anything it cannot parse. These two are
|
||||
* compile-time literals that are known-good, so a failure here is a typo in this
|
||||
* file rather than bad input — fail loudly instead of defaulting silently.
|
||||
*/
|
||||
function isoTime(value: string): ISOTimeString {
|
||||
const parsed = createISOTimeString(value);
|
||||
if (!parsed) throw new Error(`Invalid default time: ${value}`);
|
||||
@@ -59,13 +53,6 @@ const EMPTY: FormState = {
|
||||
contactno: '',
|
||||
address: '',
|
||||
suburb: '',
|
||||
// Empty, not pre-filled.
|
||||
//
|
||||
// These carried 'Coimbatore' and 'Tamil Nadu' as VALUES, not placeholders —
|
||||
// so a merchant anywhere else was submitted with the wrong city and state
|
||||
// unless somebody noticed and cleared them. A default that is right most of
|
||||
// the time is worse than a blank field, because it is only checked when it
|
||||
// looks wrong.
|
||||
city: '',
|
||||
state: '',
|
||||
postcode: '',
|
||||
@@ -77,31 +64,22 @@ const EMPTY: FormState = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Commission a branch under an existing tenant.
|
||||
* Onboard Branch Page
|
||||
*
|
||||
* Sets the store's parameters and delivery thresholds, and spawns a placeholder
|
||||
* branch-manager account. The delivery radius and estimated speed are what the
|
||||
* customer app uses to decide whether an address is servable, so they are
|
||||
* required rather than optional-with-a-default-nobody-checks.
|
||||
*
|
||||
* ── PARKED, NOT DEAD ────────────────────────────────────────────────────────
|
||||
* Branch onboarding belongs to the Store Admin, not to us: a merchant opens
|
||||
* their own outlets. This file is therefore unrouted until the Store Admin
|
||||
* workspace exists, and it lives here rather than under `nearle-admin/` so its
|
||||
* eventual owner is obvious from the path.
|
||||
*
|
||||
* Two things must change when it is wired up:
|
||||
* 1. Drop the tenant Selector. A Store Admin has exactly one tenant and it
|
||||
* comes from the session, so asking them to pick it is asking them to
|
||||
* confirm something they cannot get wrong.
|
||||
* 2. Take `tenantid` from `useAuth()` instead of form state, and let the
|
||||
* backend reject a mismatch rather than trusting the client's number.
|
||||
* Clean enterprise SaaS form matching the Onboard Tenant aesthetic and rhythm.
|
||||
*/
|
||||
export function OnboardBranchPage() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const branchScope = useBranchScopeOptional();
|
||||
const { data: tenants, isLoading: isLoadingTenants } = useTenants();
|
||||
const [form, setForm] = useState<FormState>(EMPTY);
|
||||
|
||||
const defaultTenantId = branchScope?.tenantid ? String(branchScope.tenantid) : '';
|
||||
|
||||
const [form, setForm] = useState<FormState>(() => ({
|
||||
...EMPTY,
|
||||
tenantid: defaultTenantId,
|
||||
}));
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
/** One option per tenant — `getalltenants` returns a row per branch. */
|
||||
@@ -114,15 +92,41 @@ 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 effectiveTenantId = Number(form.tenantid || defaultTenantId) || undefined;
|
||||
const staff = useStaff(effectiveTenantId);
|
||||
|
||||
/*
|
||||
The region and module a new outlet belongs to, taken from the outlets the
|
||||
same tenant already runs.
|
||||
|
||||
Neither is on this form and neither should be — a merchant opening a second
|
||||
shop in Coimbatore is not choosing a delivery region, they are in one. But
|
||||
`tenantlocations` has no column default for either, so leaving them out of
|
||||
the request stored a literal 0: measured 2026-09-15, 43 of 75 live branches
|
||||
carry `applocationid = 0`. `orderRepository.go` calls that column
|
||||
"authoritative" and has no fallback for a zero, and the backend copies it
|
||||
onto the login it spawns for the branch, so the outlet and its operator both
|
||||
ended up in no region.
|
||||
|
||||
Read from the tenant in the dropdown rather than from `BranchScope`, because
|
||||
the dropdown can name a different tenant than the session's own and the new
|
||||
outlet has to inherit from the one that will own it.
|
||||
*/
|
||||
const siblings = useTenantLocations(effectiveTenantId);
|
||||
const inherited = useMemo(() => {
|
||||
const rows = siblings.data ?? [];
|
||||
// First non-zero wins. A tenant's outlets are all in one region in
|
||||
// practice, and a zero on an older row is exactly the bug being fixed —
|
||||
// it must not be inherited forward.
|
||||
const pick = (key: 'applocationid' | 'moduleid') => {
|
||||
for (const row of rows) {
|
||||
const value = Number(row[key] ?? 0);
|
||||
if (value > 0) return value;
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
return { applocationid: pick('applocationid'), moduleid: pick('moduleid') };
|
||||
}, [siblings.data]);
|
||||
const unplaced = useMemo(() => (staff.data ?? []).filter(isUnplaced), [staff.data]);
|
||||
const operatorOptions = useMemo(
|
||||
() => [
|
||||
@@ -152,17 +156,55 @@ export function OnboardBranchPage() {
|
||||
});
|
||||
|
||||
const isComplete =
|
||||
form.tenantid !== '' &&
|
||||
(form.tenantid !== '' || defaultTenantId !== '') &&
|
||||
form.locationname.trim() !== '' &&
|
||||
form.address.trim() !== '' &&
|
||||
form.city.trim() !== '' &&
|
||||
// State carries the red asterisk on its label and was not checked here, so
|
||||
// the form claimed to require it and then accepted a blank. It is also one
|
||||
// of the five parts handed to `GeocodeAddress` server-side, and leaving it
|
||||
// out is one reason 9 of 75 live branches resolved to no coordinates.
|
||||
form.state.trim() !== '' &&
|
||||
form.postcode.trim() !== '';
|
||||
|
||||
/**
|
||||
* Somebody has to be able to sign in to the outlet.
|
||||
*
|
||||
* `CreateTenantLocation` in `tenantRepository.go` refuses outright when
|
||||
* `operatorid <= 0` and the email is blank — "a branch needs somebody to run
|
||||
* it" — because a spawned login with an empty authname is a row that can
|
||||
* never authenticate. The form did not know that rule: with "Create a login
|
||||
* for the outlet" left selected and the optional-looking email empty, every
|
||||
* required field could be filled and the save was still certain to fail.
|
||||
*
|
||||
* Checked here rather than folded into `isComplete` on purpose. A button that
|
||||
* greys out with nothing to explain it is worse than one that answers, and
|
||||
* this is the one rule on the form a person would not guess.
|
||||
*/
|
||||
function missingOperator(): string | null {
|
||||
if (Number(form.operatorid) > 0) return null;
|
||||
if (form.email.trim() !== '') return null;
|
||||
return 'This outlet needs somebody who can sign in to it. Either choose who runs it, or give an outlet email — a login will be created on that address.';
|
||||
}
|
||||
|
||||
function handleSubmit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
if (!isComplete || mutation.isPending) return;
|
||||
|
||||
const missing = missingOperator();
|
||||
if (missing !== null) {
|
||||
setError(missing);
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
mutation.mutate({
|
||||
tenantid: Number(form.tenantid),
|
||||
tenantid: Number(form.tenantid || defaultTenantId),
|
||||
// Only when the tenant's own outlets actually tell us. Sending a 0
|
||||
// explicitly is no better than omitting it, and a wrong guess would put
|
||||
// the shop in the wrong city.
|
||||
...(inherited.applocationid > 0 ? { applocationid: inherited.applocationid } : {}),
|
||||
...(inherited.moduleid > 0 ? { moduleid: inherited.moduleid } : {}),
|
||||
locationname: form.locationname.trim(),
|
||||
email: form.email.trim(),
|
||||
contactno: form.contactno.trim(),
|
||||
@@ -175,9 +217,6 @@ 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',
|
||||
});
|
||||
@@ -185,90 +224,120 @@ export function OnboardBranchPage() {
|
||||
|
||||
if (mutation.isSuccess) {
|
||||
return (
|
||||
<VStack gap={3}>
|
||||
<PageHeader title="Branch commissioned" />
|
||||
<Card padding={4} elevation="low">
|
||||
<VStack gap={3}>
|
||||
<HStack align="center" gap={1.5}>
|
||||
<CheckCircle2 size={22} style={{ color: 'var(--color-success, #10b981)' }} />
|
||||
<Text type="large" weight="semibold">
|
||||
{form.locationname} is commissioned
|
||||
</Text>
|
||||
</HStack>
|
||||
<Text type="body" color="secondary">
|
||||
{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>
|
||||
<PageBody measure="reading">
|
||||
<VStack gap={3}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/sales')}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
padding: '4px 0',
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
color: 'var(--color-ink-3, #657081)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<ArrowLeft size={14} />
|
||||
<span>Back</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Its own storefront code, and it has to be its own: the payload is
|
||||
{'{'}tenantid, locationid{'}'}, so a second outlet that reused the
|
||||
first one's poster would send every shopper to the first shop.
|
||||
`createtenantlocation` is used precisely because it returns the
|
||||
created row — the new locationid is what this needs. */}
|
||||
{mutation.data?.locationid ? (
|
||||
<Card padding={3} elevation="low" variant="transparent">
|
||||
<StoreQrPanel
|
||||
tenantid={Number(form.tenantid)}
|
||||
locationid={mutation.data.locationid}
|
||||
locationname={form.locationname}
|
||||
where={[form.suburb, form.city].filter(Boolean).join(', ')}
|
||||
<Card padding={4} elevation="low">
|
||||
<VStack gap={3}>
|
||||
<HStack align="center" gap={1.5}>
|
||||
<CheckCircle2 size={24} style={{ color: 'var(--color-success, #10b981)' }} />
|
||||
<Text type="large" weight="semibold">
|
||||
{form.locationname} is commissioned
|
||||
</Text>
|
||||
</HStack>
|
||||
<Text type="body" color="secondary">
|
||||
{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>
|
||||
|
||||
{mutation.data?.locationid ? (
|
||||
<Card padding={3} elevation="low" variant="transparent">
|
||||
<StoreQrPanel
|
||||
tenantid={Number(form.tenantid || defaultTenantId)}
|
||||
locationid={mutation.data.locationid}
|
||||
locationname={form.locationname}
|
||||
where={[form.suburb, form.city].filter(Boolean).join(', ')}
|
||||
/>
|
||||
</Card>
|
||||
) : null}
|
||||
<HStack gap={1.5} wrap="wrap">
|
||||
<Button
|
||||
label="Commission another"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
mutation.reset();
|
||||
setForm({ ...EMPTY, tenantid: form.tenantid || defaultTenantId });
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
) : null}
|
||||
<HStack gap={1.5} wrap="wrap">
|
||||
<Button
|
||||
label="Commission another"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
mutation.reset();
|
||||
setForm({ ...EMPTY, tenantid: form.tenantid });
|
||||
}}
|
||||
/>
|
||||
<Button label="Back to stores" variant="primary" onClick={() => navigate('/nearle/stores')} />
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Card>
|
||||
</VStack>
|
||||
<Button label="Back to dashboard" variant="primary" onClick={() => navigate('/sales')} />
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Card>
|
||||
</VStack>
|
||||
</PageBody>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageBody measure="reading">
|
||||
<PageHeader
|
||||
title="Onboard branch"
|
||||
/>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<form onSubmit={handleSubmit} style={{ width: '100%' }}>
|
||||
<VStack gap={3}>
|
||||
<h1 className="sr-only">Onboard Branch</h1>
|
||||
|
||||
{/* Section 1: Business Details & Identity */}
|
||||
<Card padding={0} elevation="low">
|
||||
<VStack gap={2} padding={3}>
|
||||
<SectionHeader
|
||||
title="Owner and identity"
|
||||
action={<Store size={17} style={{ color: 'var(--color-slate-400)' }} />}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
paddingBottom: 10,
|
||||
borderBottom: '1px solid var(--color-line, #e0e4ea)',
|
||||
}}
|
||||
>
|
||||
<Store size={16} style={{ color: 'var(--color-brand, #662582)' }} />
|
||||
<span
|
||||
style={{
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
color: 'var(--color-ink-1, #0f172a)',
|
||||
}}
|
||||
>
|
||||
Branch Details
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="form-grid">
|
||||
<Selector
|
||||
label={<span>Owner tenant <span style={{ color: 'var(--color-error)' }}>*</span></span> as any}
|
||||
options={tenantOptions}
|
||||
value={form.tenantid}
|
||||
onChange={set('tenantid')}
|
||||
placeholder={isLoadingTenants ? 'Loading tenants…' : 'Select tenant…'}
|
||||
isLoading={isLoadingTenants}
|
||||
hasSearch
|
||||
/>
|
||||
{!defaultTenantId && (
|
||||
<Selector
|
||||
label={<span>Owner tenant <span style={{ color: 'var(--color-error, #d64545)' }}>*</span></span> as any}
|
||||
options={tenantOptions}
|
||||
value={form.tenantid}
|
||||
onChange={set('tenantid')}
|
||||
placeholder={isLoadingTenants ? 'Loading tenants…' : 'Select tenant…'}
|
||||
isDisabled={isLoadingTenants}
|
||||
hasSearch
|
||||
/>
|
||||
)}
|
||||
<TextInput
|
||||
label={<span>Outlet name <span style={{ color: 'var(--color-error)' }}>*</span></span> as any}
|
||||
label={<span>Outlet name <span style={{ color: 'var(--color-error, #d64545)' }}>*</span></span> as any}
|
||||
value={form.locationname}
|
||||
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}
|
||||
@@ -279,12 +348,7 @@ export function OnboardBranchPage() {
|
||||
? '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}
|
||||
isDisabled={!effectiveTenantId}
|
||||
/>
|
||||
<TextInput
|
||||
label="Outlet email"
|
||||
@@ -292,11 +356,6 @@ export function OnboardBranchPage() {
|
||||
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"
|
||||
@@ -308,33 +367,90 @@ export function OnboardBranchPage() {
|
||||
</VStack>
|
||||
</Card>
|
||||
|
||||
{/* Section 2: Location */}
|
||||
<Card padding={0} elevation="low">
|
||||
<VStack gap={2} padding={3}>
|
||||
<SectionHeader
|
||||
title="Location"
|
||||
action={<MapPin size={17} style={{ color: 'var(--color-slate-400)' }} />}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
paddingBottom: 10,
|
||||
borderBottom: '1px solid var(--color-line, #e0e4ea)',
|
||||
}}
|
||||
>
|
||||
<MapPin size={16} style={{ color: 'var(--color-brand, #662582)' }} />
|
||||
<span
|
||||
style={{
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
color: 'var(--color-ink-1, #0f172a)',
|
||||
}}
|
||||
>
|
||||
Location
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<TextInput
|
||||
label={<span>Street address <span style={{ color: 'var(--color-error)' }}>*</span></span> as any}
|
||||
label={<span>Street address <span style={{ color: 'var(--color-error, #d64545)' }}>*</span></span> as any}
|
||||
value={form.address}
|
||||
onChange={set('address')}
|
||||
placeholder="e.g. 240, DB Road"
|
||||
/>
|
||||
|
||||
<div className="form-grid-4">
|
||||
<TextInput label="Suburb" value={form.suburb} onChange={set('suburb')} placeholder="e.g. RS Puram" />
|
||||
<TextInput label={<span>City <span style={{ color: 'var(--color-error)' }}>*</span></span> as any} value={form.city} onChange={set('city')} />
|
||||
<TextInput label={<span>State <span style={{ color: 'var(--color-error)' }}>*</span></span> as any} value={form.state} onChange={set('state')} />
|
||||
<TextInput label={<span>Postcode <span style={{ color: 'var(--color-error)' }}>*</span></span> as any} value={form.postcode} onChange={set('postcode')} />
|
||||
<TextInput
|
||||
label="Suburb"
|
||||
value={form.suburb}
|
||||
onChange={set('suburb')}
|
||||
placeholder="e.g. RS Puram"
|
||||
/>
|
||||
<TextInput
|
||||
label={<span>City <span style={{ color: 'var(--color-error, #d64545)' }}>*</span></span> as any}
|
||||
value={form.city}
|
||||
onChange={set('city')}
|
||||
placeholder="e.g. Coimbatore"
|
||||
/>
|
||||
<TextInput
|
||||
label={<span>State <span style={{ color: 'var(--color-error, #d64545)' }}>*</span></span> as any}
|
||||
value={form.state}
|
||||
onChange={set('state')}
|
||||
placeholder="e.g. Tamil Nadu"
|
||||
/>
|
||||
<TextInput
|
||||
label={<span>Postcode <span style={{ color: 'var(--color-error, #d64545)' }}>*</span></span> as any}
|
||||
value={form.postcode}
|
||||
onChange={set('postcode')}
|
||||
placeholder="641002"
|
||||
/>
|
||||
</div>
|
||||
</VStack>
|
||||
</Card>
|
||||
|
||||
{/* Section 3: Operating Hours & Delivery */}
|
||||
<Card padding={0} elevation="low">
|
||||
<VStack gap={2} padding={3}>
|
||||
<SectionHeader
|
||||
title="Hours and delivery"
|
||||
action={<Clock size={17} style={{ color: 'var(--color-slate-400)' }} />}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
paddingBottom: 10,
|
||||
borderBottom: '1px solid var(--color-line, #e0e4ea)',
|
||||
}}
|
||||
>
|
||||
<Clock size={16} style={{ color: 'var(--color-brand, #662582)' }} />
|
||||
<span
|
||||
style={{
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
color: 'var(--color-ink-1, #0f172a)',
|
||||
}}
|
||||
>
|
||||
Hours and Delivery
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="form-grid-4">
|
||||
<TimeInput
|
||||
label="Open time"
|
||||
@@ -368,36 +484,71 @@ export function OnboardBranchPage() {
|
||||
</VStack>
|
||||
</Card>
|
||||
|
||||
{/* Error Alert */}
|
||||
{error ? (
|
||||
<HStack
|
||||
align="center"
|
||||
gap={1}
|
||||
padding={2}
|
||||
padding={1.5}
|
||||
style={{
|
||||
background: 'var(--color-error-muted, #fceeee)',
|
||||
borderRadius: 12,
|
||||
borderRadius: 8,
|
||||
color: 'var(--color-error, #d64545)',
|
||||
border: '1px solid var(--color-error-border, #fad1d1)',
|
||||
}}
|
||||
>
|
||||
<AlertCircle size={17} />
|
||||
<AlertCircle size={15} />
|
||||
<Text type="body" size="sm" style={{ color: 'inherit' }}>
|
||||
{error}
|
||||
</Text>
|
||||
</HStack>
|
||||
) : null}
|
||||
|
||||
<HStack justify="end">
|
||||
{/* Sticky Bottom Actions */}
|
||||
<div
|
||||
style={{
|
||||
position: 'sticky',
|
||||
bottom: 0,
|
||||
zIndex: 20,
|
||||
padding: '12px 18px',
|
||||
background: 'rgba(255, 255, 255, 0.94)',
|
||||
backdropFilter: 'blur(8px)',
|
||||
border: '1px solid var(--color-line, #e0e4ea)',
|
||||
borderRadius: 10,
|
||||
boxShadow: '0 4px 16px -2px rgba(15, 23, 42, 0.08)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 12,
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
label="Cancel"
|
||||
onClick={() => navigate(-1)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
label={mutation.isPending ? 'Commissioning…' : 'Commission branch'}
|
||||
type="submit"
|
||||
variant="primary"
|
||||
size="lg"
|
||||
label={mutation.isPending ? 'Commissioning…' : 'Save & Continue'}
|
||||
isLoading={mutation.isPending}
|
||||
isDisabled={!isComplete}
|
||||
/>
|
||||
</HStack>
|
||||
</div>
|
||||
</VStack>
|
||||
</form>
|
||||
</PageBody>
|
||||
);
|
||||
}
|
||||
|
||||
/** Safe hook for branch scope */
|
||||
function useBranchScopeOptional() {
|
||||
try {
|
||||
return useBranchScope();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,10 +4,11 @@ import { HStack } from '@astryxdesign/core/HStack';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { TextInput } from '@astryxdesign/core/TextInput';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { Mail, MapPin, Phone } from 'lucide-react';
|
||||
import { Mail, MapPin, Phone, Repeat, ShoppingBag, Users } from 'lucide-react';
|
||||
import { customerLocality, customerName, type CustomerInfo } from '@/api/customers';
|
||||
import { KpiCard } from '@/components/KpiCard';
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
import { useCustomers } from '@/queries/hooks';
|
||||
import { useCustomers, useOrders } from '@/queries/hooks';
|
||||
import { useBranchScope } from '@/features/store-admin/BranchScope';
|
||||
import { Drawer } from '@/features/store-admin/Drawer';
|
||||
import { count } from '@/features/store-admin/format';
|
||||
@@ -16,39 +17,109 @@ import { TablePager } from '@/components/TablePager';
|
||||
import { usePaged } from '@/components/usePaged';
|
||||
|
||||
/**
|
||||
* Customers, for one shop.
|
||||
* The shop's customers.
|
||||
*
|
||||
* Read-only, and that is the whole surface. The old console's customer cards
|
||||
* carried "Send promo SMS" and "Issue store credit" buttons; both were hidden
|
||||
* from a store user by a `canManage` flag, and both were `toast()` calls with
|
||||
* no endpoint behind them for anyone. There is nothing to port.
|
||||
* ── Built around what this backend actually fills in ────────────────────────
|
||||
*
|
||||
* The list is genuinely branch-scoped by the server — `gettenantcustomers`
|
||||
* joins `tenantcustomers` on `locationid` — which makes it one of the few reads
|
||||
* here that would still be correct if someone bypassed this client.
|
||||
* `gettenantcustomers` returns 35 columns and most of them are empty. Measured
|
||||
* across 31 real customers at 7 shops on 2026-09-15:
|
||||
*
|
||||
* firstname, contactno, address, suburb, city, state, postcode 100%
|
||||
* latitude / longitude 97%
|
||||
* doorno 71% landmark 65% email 42%
|
||||
* lastname, gender, dob, status, profileimage, devicetype … 0%
|
||||
*
|
||||
* Three things follow from that, and they are the whole design.
|
||||
*
|
||||
* Email had a column of its own and was blank in 58% of rows, so a quarter of
|
||||
* the table's width carried more dashes than addresses. It now sits under the
|
||||
* name, where it shows for the 42% who have one and costs nothing for the rest.
|
||||
*
|
||||
* `lastname` is empty for every customer on the platform, so `customerName`
|
||||
* always resolves to the first name alone. No field, column or sort pretends
|
||||
* otherwise.
|
||||
*
|
||||
* `status` is empty for every customer too — hence no Active/Inactive chip. A
|
||||
* badge that reads the same on every row is decoration.
|
||||
*
|
||||
* ── Why orders are here ─────────────────────────────────────────────────────
|
||||
*
|
||||
* A contact list is not worth a page. What a shop wants to know is who comes
|
||||
* back. Order rows carry `customerid` and it resolves: 290 of 300 orders at
|
||||
* tenant 1087 matched a listed customer. `deliverycustomerid` is 0 everywhere
|
||||
* and is NOT used.
|
||||
*
|
||||
* Counted over ALL time, deliberately NOT the header's date range. Branch 1097
|
||||
* has 500 orders in its history and none of them fall in Sep 1-15, so a
|
||||
* range-scoped count renders a column of dashes and calls every customer new —
|
||||
* the email column's mistake in a different costume. It also made "came back"
|
||||
* swing with the date picker, when whether somebody is a repeat customer is not
|
||||
* a property of the fortnight you happen to be looking at.
|
||||
*
|
||||
* Money is absent for a related reason: `ordervalue` is 0 on all 300 order rows
|
||||
* at tenant 1087, so a spend column would read zero for an entire shop.
|
||||
*
|
||||
* The read is capped at 500 rows, as every order read in this console is, so
|
||||
* for a very busy shop the count is a floor rather than a total.
|
||||
*/
|
||||
export function StoreCustomersPage() {
|
||||
const { current, tenantid } = useBranchScope();
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [open, setOpen] = useState<CustomerInfo | null>(null);
|
||||
|
||||
const locationid = current?.locationid;
|
||||
|
||||
const customers = useCustomers(
|
||||
tenantid && current ? { tenantid, locationid: current.locationid, pagesize: 200 } : undefined,
|
||||
tenantid ? { tenantid, locationid, keyword: keyword || undefined, pagesize: 200 } : undefined,
|
||||
);
|
||||
|
||||
/* No date range — see the note above. A customer's order history is the
|
||||
question here, not this fortnight's trading. */
|
||||
const orders = useOrders(
|
||||
tenantid
|
||||
? { tenantid, ...(locationid ? { locationid } : {}), pagesize: 500 }
|
||||
: undefined,
|
||||
);
|
||||
|
||||
/** Orders per customer, keyed on `customerid`. */
|
||||
const ordersByCustomer = useMemo(() => {
|
||||
const tally = new Map<number, number>();
|
||||
for (const row of orders.data ?? []) {
|
||||
const id = Number(row.customerid ?? 0);
|
||||
if (id > 0) tally.set(id, (tally.get(id) ?? 0) + 1);
|
||||
}
|
||||
return tally;
|
||||
}, [orders.data]);
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const term = keyword.trim().toLowerCase();
|
||||
const list = customers.data ?? [];
|
||||
if (term === '') return list;
|
||||
const needle = keyword.trim().toLowerCase();
|
||||
if (!needle) return list;
|
||||
return list.filter((customer) =>
|
||||
`${customerName(customer)} ${customer.contactno ?? ''} ${customer.email ?? ''} ${customer.address ?? ''}`
|
||||
.toLowerCase()
|
||||
.includes(term),
|
||||
.includes(needle),
|
||||
);
|
||||
}, [customers.data, keyword]);
|
||||
|
||||
const paged = usePaged(rows, { resetKey: keyword });
|
||||
|
||||
const withPhone = rows.filter((customer) => Boolean(customer.contactno)).length;
|
||||
const repeat = rows.filter((customer) => (ordersByCustomer.get(customer.customerid) ?? 0) > 1)
|
||||
.length;
|
||||
|
||||
/* Where the shop's customers actually are. Suburb is filled on every row, so
|
||||
this is a real answer rather than a mostly-empty one — and it is the one
|
||||
fact on this page a shopkeeper cannot get from their own memory. */
|
||||
const topLocality = useMemo(() => {
|
||||
const tally = new Map<string, number>();
|
||||
for (const customer of rows) {
|
||||
const area = (customer.suburb || customer.city || '').trim();
|
||||
if (area) tally.set(area, (tally.get(area) ?? 0) + 1);
|
||||
}
|
||||
const best = [...tally.entries()].sort((a, b) => b[1] - a[1])[0];
|
||||
return best ? { area: best[0], n: best[1] } : null;
|
||||
}, [rows]);
|
||||
|
||||
return (
|
||||
<VStack gap={3}>
|
||||
@@ -69,6 +140,33 @@ export function StoreCustomersPage() {
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="kpi-grid">
|
||||
<KpiCard
|
||||
label="Customers"
|
||||
value={count(rows.length)}
|
||||
tone="accent"
|
||||
icon={<Users size={15} />}
|
||||
/>
|
||||
<KpiCard
|
||||
label="Reachable"
|
||||
value={count(withPhone)}
|
||||
tone={withPhone === rows.length ? 'success' : 'warning'}
|
||||
icon={<Phone size={15} />}
|
||||
/>
|
||||
<KpiCard
|
||||
label="Came back"
|
||||
value={count(repeat)}
|
||||
tone={repeat > 0 ? 'success' : 'neutral'}
|
||||
icon={<Repeat size={15} />}
|
||||
/>
|
||||
<KpiCard
|
||||
label="Biggest area"
|
||||
value={topLocality ? topLocality.area : '—'}
|
||||
tone="neutral"
|
||||
icon={<MapPin size={15} />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{customers.isLoading ? (
|
||||
<Note title="Reading your customer list…" />
|
||||
) : rows.length === 0 ? (
|
||||
@@ -83,8 +181,9 @@ export function StoreCustomersPage() {
|
||||
) : (
|
||||
<VStack gap={1.5}>
|
||||
<Text type="body" size="sm" color="secondary">
|
||||
{count(rows.length)} customer{rows.length === 1 ? '' : 's'} · {count(withPhone)} with a
|
||||
phone number
|
||||
{count(rows.length)} customer{rows.length === 1 ? '' : 's'}
|
||||
{topLocality ? ` · ${count(topLocality.n)} in ${topLocality.area}` : ''} · orders counted
|
||||
over their whole history
|
||||
</Text>
|
||||
<Card padding={0} elevation="low">
|
||||
<div className="table-scroll">
|
||||
@@ -97,37 +196,57 @@ export function StoreCustomersPage() {
|
||||
}}
|
||||
>
|
||||
<colgroup>
|
||||
<col style={{ width: '26%' }} />
|
||||
<col style={{ width: '18%' }} />
|
||||
<col style={{ width: '26%' }} />
|
||||
<col style={{ width: '30%' }} />
|
||||
<col style={{ width: '38%' }} />
|
||||
<col style={{ width: '20%' }} />
|
||||
<col style={{ width: '28%' }} />
|
||||
<col style={{ width: '14%' }} />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<Th>Customer</Th>
|
||||
<Th>Phone</Th>
|
||||
<Th>Email</Th>
|
||||
<Th>Where</Th>
|
||||
<Th align="end">Orders</Th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{paged.rows.map((customer) => (
|
||||
<Row key={customer.customerid} customer={customer} onOpen={() => setOpen(customer)} />
|
||||
<Row
|
||||
key={customer.customerid}
|
||||
customer={customer}
|
||||
orders={ordersByCustomer.get(customer.customerid) ?? 0}
|
||||
onOpen={() => setOpen(customer)}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<TablePager paged={paged} label="customers" />
|
||||
<TablePager paged={paged} label="customers" />
|
||||
</Card>
|
||||
</VStack>
|
||||
)}
|
||||
|
||||
{open ? <CustomerDrawer customer={open} onClose={() => setOpen(null)} /> : null}
|
||||
{open ? (
|
||||
<CustomerDrawer
|
||||
customer={open}
|
||||
orders={ordersByCustomer.get(open.customerid) ?? 0}
|
||||
rangeLabel="in total"
|
||||
onClose={() => setOpen(null)}
|
||||
/>
|
||||
) : null}
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ customer, onOpen }: { customer: CustomerInfo; onOpen: () => void }) {
|
||||
function Row({
|
||||
customer,
|
||||
orders,
|
||||
onOpen,
|
||||
}: {
|
||||
customer: CustomerInfo;
|
||||
orders: number;
|
||||
onOpen: () => void;
|
||||
}) {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
return (
|
||||
<tr
|
||||
@@ -152,26 +271,33 @@ function Row({ customer, onOpen }: { customer: CustomerInfo; onOpen: () => void
|
||||
<Td>
|
||||
<HStack gap={1.5} align="center">
|
||||
<Initials name={customerName(customer)} />
|
||||
<Text type="label" size="sm" weight="semibold" maxLines={1}>
|
||||
{customerName(customer)}
|
||||
</Text>
|
||||
{/* The email rides under the name rather than holding a column of its
|
||||
own: 42% of customers have one, so as a column it was mostly
|
||||
dashes, and here its absence costs nothing at all. */}
|
||||
<span style={{ minWidth: 0 }}>
|
||||
<Text type="label" size="sm" weight="semibold" maxLines={1}>
|
||||
{customerName(customer)}
|
||||
</Text>
|
||||
{customer.email ? (
|
||||
<span
|
||||
style={{
|
||||
display: 'block',
|
||||
fontSize: 11.5,
|
||||
color: 'var(--color-ink-4)',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{customer.email}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</HStack>
|
||||
</Td>
|
||||
<Td isMuted>
|
||||
<span style={{ fontVariantNumeric: 'tabular-nums' }}>{customer.contactno || '—'}</span>
|
||||
</Td>
|
||||
<Td isMuted>
|
||||
<span
|
||||
style={{
|
||||
display: 'block',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{customer.email || '—'}
|
||||
</span>
|
||||
</Td>
|
||||
<Td isMuted>
|
||||
<span
|
||||
style={{
|
||||
@@ -184,17 +310,41 @@ function Row({ customer, onOpen }: { customer: CustomerInfo; onOpen: () => void
|
||||
{customerLocality(customer)}
|
||||
</span>
|
||||
</Td>
|
||||
{/* Right-aligned and tabular so the column can be scanned down for the
|
||||
repeat buyers, which is the one question this page exists to answer. */}
|
||||
<Td align="end">
|
||||
{orders === 0 ? (
|
||||
<span style={{ color: 'var(--color-ink-4)' }}>—</span>
|
||||
) : (
|
||||
<span
|
||||
style={{
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
fontWeight: orders > 1 ? 600 : 400,
|
||||
color: orders > 1 ? 'var(--color-brand)' : 'var(--color-ink-2)',
|
||||
}}
|
||||
>
|
||||
{count(orders)}
|
||||
</span>
|
||||
)}
|
||||
</Td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
function CustomerDrawer({
|
||||
customer,
|
||||
orders,
|
||||
rangeLabel,
|
||||
onClose,
|
||||
}: {
|
||||
customer: CustomerInfo;
|
||||
orders: number;
|
||||
rangeLabel: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
/* doorno and landmark are filled on 71% and 65% of rows, so the assembled
|
||||
address is genuinely better than the `address` column alone — which is why
|
||||
both are included and neither is relied on. */
|
||||
const address = [
|
||||
customer.doorno,
|
||||
customer.address,
|
||||
@@ -214,17 +364,39 @@ function CustomerDrawer({
|
||||
width={420}
|
||||
onClose={onClose}
|
||||
>
|
||||
<Card padding={0} elevation="low">
|
||||
<VStack gap={1} padding={2}>
|
||||
<Line icon={<Phone size={13} />} label="Phone" value={customer.contactno || '—'} />
|
||||
<Line icon={<Mail size={13} />} label="Email" value={customer.email || '—'} />
|
||||
<Line icon={<MapPin size={13} />} label="Address" value={address || '—'} />
|
||||
</VStack>
|
||||
</Card>
|
||||
<VStack gap={1.5}>
|
||||
<Card padding={0} elevation="low">
|
||||
<VStack gap={1} padding={2}>
|
||||
<Line icon={<Phone size={13} />} label="Phone" value={customer.contactno || '—'} />
|
||||
<Line icon={<Mail size={13} />} label="Email" value={customer.email || '—'} />
|
||||
<Line icon={<MapPin size={13} />} label="Address" value={address || '—'} />
|
||||
</VStack>
|
||||
</Card>
|
||||
<Card padding={0} elevation="low">
|
||||
<VStack gap={1} padding={2}>
|
||||
<Line
|
||||
icon={<ShoppingBag size={13} />}
|
||||
label="Orders"
|
||||
value={orders === 0 ? `None ${rangeLabel}` : `${count(orders)} ${rangeLabel}`}
|
||||
/>
|
||||
{/* 97% of customers have coordinates. Shown as a reference rather
|
||||
than a map: this is a drawer, and a rider's app is where a pin
|
||||
actually gets used. */}
|
||||
<Line
|
||||
icon={<MapPin size={13} />}
|
||||
label="Map ref"
|
||||
value={
|
||||
customer.latitude && customer.longitude
|
||||
? `${customer.latitude}, ${customer.longitude}`
|
||||
: 'Not placed'
|
||||
}
|
||||
/>
|
||||
</VStack>
|
||||
</Card>
|
||||
</VStack>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
function Line({
|
||||
icon,
|
||||
label,
|
||||
|
||||
@@ -1,10 +1,21 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Button } from '@astryxdesign/core/Button';
|
||||
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 { Plus } from 'lucide-react';
|
||||
import {
|
||||
Eye,
|
||||
EyeOff,
|
||||
KeyRound,
|
||||
Phone,
|
||||
Plus,
|
||||
ShieldCheck,
|
||||
TriangleAlert,
|
||||
Users,
|
||||
} from 'lucide-react';
|
||||
import type { PosUser } from '@/api/types';
|
||||
import { KpiCard } from '@/components/KpiCard';
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
import { usePosRoles, usePosUsersByBranch, useStaffShifts } from '@/queries/hooks';
|
||||
import { useBranchScope } from '@/features/store-admin/BranchScope';
|
||||
@@ -15,45 +26,91 @@ import { TablePager } from '@/components/TablePager';
|
||||
import { usePaged } from '@/components/usePaged';
|
||||
|
||||
/**
|
||||
* Counter staff — the people who can open this shop's till.
|
||||
* The people who work this shop's till.
|
||||
*
|
||||
* Cashiers only. A supervisor runs the terminal and can create their own
|
||||
* cashiers there, so issuing one is a merchant act; the backend takes the same
|
||||
* line from the other side, where only roleid 7 may manage till users at all.
|
||||
* ── Built around what `getposusers` actually returns ────────────────────────
|
||||
*
|
||||
* Two things are worth knowing before reading the table. A till account has no
|
||||
* console login — the backend leaves roles 7 and 8 out of every web lookup, so
|
||||
* a cashier signing in here is not refused, they are simply not found. And
|
||||
* there is no delete: deactivating stops the login and keeps their bills, which
|
||||
* a delete would orphan.
|
||||
* Measured across all 14 live till accounts at 6 branches on 2026-09-15:
|
||||
*
|
||||
* user_id, full_name, first_name, authname, role_id, role,
|
||||
* pin, has_password, location_id, status 100%
|
||||
* contactno 50%
|
||||
* shift_name / shift_start / shift_end 0%
|
||||
*
|
||||
* The old table spent a sixth of its width on a Shift column. Those fields
|
||||
* exist on the model with `omitempty` and are filled for nobody —
|
||||
* `getstaffshifts` answers `{"shifts": []}` at every branch — so the column
|
||||
* read "Any" on every row of every shop. A shift now rides under the name,
|
||||
* where it appears if one is ever set and takes no space while none is.
|
||||
*
|
||||
* `contactno` got the same treatment for the same reason at 50%.
|
||||
*
|
||||
* ── The field that was missing and mattered most ────────────────────────────
|
||||
*
|
||||
* `authname` — the username the cashier types at the till
|
||||
* (`cashier.1137@pos.nearle.in`). On every row, shown nowhere. A supervisor
|
||||
* handing over a shift needs to read it out, and the only place to find it was
|
||||
* the database.
|
||||
*
|
||||
* ── What `has_password` is NOT used for ─────────────────────────────────────
|
||||
*
|
||||
* It is on every row and it is always true, so nothing is built on it.
|
||||
*
|
||||
* This page briefly carried a "waiting on a password" card and an alert banner
|
||||
* for the accounts that could not sign in. There are none, and there cannot be:
|
||||
* `posUserRepository.go` generates one when the caller supplies nothing —
|
||||
* `if password == "" { password = newPosPassword() }`, under a comment reading
|
||||
* "Every till account gets a username and a password, cashiers included". All
|
||||
* 14 live accounts return `has_password: true`. A banner that can never fire is
|
||||
* the Shift column's mistake with a louder voice.
|
||||
*
|
||||
* ── Why inactive accounts are asked for ─────────────────────────────────────
|
||||
*
|
||||
* The drawer can switch a cashier off, and the listing hides inactive rows
|
||||
* unless `include_inactive=true` is sent — which nothing sent. So switching
|
||||
* somebody off made them vanish from this page entirely, with no trace and no
|
||||
* way to switch them back. They now stay, marked Suspended. That is also what
|
||||
* makes the Access column worth its width: with only active rows it could
|
||||
* report one value forever.
|
||||
*/
|
||||
export function StoreStaffPage() {
|
||||
const { current, tenantid } = useBranchScope();
|
||||
const locationid = current?.locationid;
|
||||
|
||||
const [editing, setEditing] = useState<PosUser | null>(null);
|
||||
const [isAdding, setAdding] = useState(false);
|
||||
|
||||
const pages = usePosUsersByBranch(tenantid || undefined, locationid ? [locationid] : []);
|
||||
const locationid = current?.locationid;
|
||||
|
||||
// Inactive included on purpose — see the note above.
|
||||
const pages = usePosUsersByBranch(tenantid || undefined, locationid ? [locationid] : [], true);
|
||||
const roles = usePosRoles();
|
||||
const shifts = useStaffShifts(tenantid || undefined, locationid);
|
||||
|
||||
const page = pages[0];
|
||||
|
||||
const everyone = useMemo(() => page?.data ?? [], [page?.data]);
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const list = page?.data ?? [];
|
||||
// Cashiers only. A supervisor may exist at this outlet — the merchant
|
||||
// issued them — but they are not this page's to manage, and showing a row
|
||||
// whose Edit button would be refused is worse than not showing it.
|
||||
// whose Edit button would be refused is worse than not showing it. Their
|
||||
// presence is still worth knowing, so they are counted in the cards above
|
||||
// rather than hidden entirely.
|
||||
//
|
||||
// No search box: a counter has a handful of staff, and a filter over four
|
||||
// rows is a control that only ever gets in the way.
|
||||
return list
|
||||
return everyone
|
||||
.filter((person) => (person.role ?? '').toLowerCase() !== 'supervisor')
|
||||
.sort((a, b) => (a.full_name ?? '').localeCompare(b.full_name ?? ''));
|
||||
}, [page?.data]);
|
||||
}, [everyone]);
|
||||
|
||||
const paged = usePaged(rows);
|
||||
|
||||
const active = rows.filter((person) => (person.status ?? '').toLowerCase() !== 'inactive').length;
|
||||
const supervisors = everyone.filter(
|
||||
(person) => (person.role ?? '').toLowerCase() === 'supervisor',
|
||||
).length;
|
||||
const ready = rows.filter((person) => accessOf(person) === 'ready').length;
|
||||
const suspended = rows.filter((person) => accessOf(person) === 'suspended').length;
|
||||
const reachable = rows.filter((person) => Boolean(person.contactno)).length;
|
||||
|
||||
return (
|
||||
<VStack gap={3}>
|
||||
@@ -71,17 +128,76 @@ export function StoreStaffPage() {
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="kpi-grid">
|
||||
<KpiCard
|
||||
label="Cashiers"
|
||||
value={count(rows.length)}
|
||||
tone="accent"
|
||||
icon={<Users size={15} />}
|
||||
/>
|
||||
<KpiCard
|
||||
label="Can open the till"
|
||||
value={count(ready)}
|
||||
tone={rows.length > 0 && ready === rows.length ? 'success' : 'warning'}
|
||||
icon={<ShieldCheck size={15} />}
|
||||
/>
|
||||
{/* Contact details are filled on half the accounts, so this is the one
|
||||
card here whose number genuinely moves between shops. */}
|
||||
<KpiCard
|
||||
label="Reachable"
|
||||
value={count(reachable)}
|
||||
tone={rows.length > 0 && reachable === rows.length ? 'success' : 'warning'}
|
||||
icon={<Phone size={15} />}
|
||||
/>
|
||||
{/* A shop with no supervisor cannot do the things only a supervisor
|
||||
can — imports, voids, settings — so its absence is worth flagging. */}
|
||||
<KpiCard
|
||||
label="Supervisors"
|
||||
value={count(supervisors)}
|
||||
tone={supervisors > 0 ? 'neutral' : 'warning'}
|
||||
icon={<KeyRound size={15} />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Said once rather than left as a chip on a row somebody has to notice.
|
||||
A suspended cashier is refused by the till, and now that these rows
|
||||
are asked for rather than hidden, the page can say so. */}
|
||||
{suspended > 0 ? (
|
||||
<Card padding={0} elevation="low">
|
||||
<HStack gap={1.5} padding={2} align="start">
|
||||
<span style={{ color: 'var(--color-ink-4)', flex: 'none', marginTop: 1 }}>
|
||||
<TriangleAlert size={16} />
|
||||
</span>
|
||||
<VStack gap={0.5}>
|
||||
<Text type="label" size="sm" weight="semibold">
|
||||
{suspended === 1
|
||||
? 'One cashier is switched off'
|
||||
: `${count(suspended)} cashiers are switched off`}
|
||||
</Text>
|
||||
<Text type="body" size="sm" color="secondary" style={{ lineHeight: 1.6 }}>
|
||||
The till will refuse{' '}
|
||||
{suspended === 1 ? 'them' : 'them'} until the account is switched back on. They are
|
||||
listed below rather than hidden, so Edit can undo it.
|
||||
</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{page?.isLoading ? (
|
||||
<Note title="Reading your counter staff…" />
|
||||
) : rows.length === 0 ? (
|
||||
<Note
|
||||
title="No cashiers yet"
|
||||
body="Add one and they can open the till with their mobile number and PIN."
|
||||
body="Add one and they can open the till with their own username and password."
|
||||
/>
|
||||
) : (
|
||||
<VStack gap={1.5}>
|
||||
<Text type="body" size="sm" color="secondary">
|
||||
{count(rows.length)} cashier{rows.length === 1 ? '' : 's'} · {count(active)} active
|
||||
{count(rows.length)} cashier{rows.length === 1 ? '' : 's'} · {count(ready)} ready to work
|
||||
{supervisors > 0
|
||||
? ` · ${count(supervisors)} supervisor${supervisors === 1 ? '' : 's'} at this outlet`
|
||||
: ' · no supervisor at this outlet'}
|
||||
</Text>
|
||||
<Card padding={0} elevation="low">
|
||||
<div className="table-scroll">
|
||||
@@ -94,51 +210,66 @@ export function StoreStaffPage() {
|
||||
}}
|
||||
>
|
||||
<colgroup>
|
||||
<col style={{ width: '24%' }} />
|
||||
<col style={{ width: '26%' }} />
|
||||
<col style={{ width: '14%' }} />
|
||||
<col style={{ width: '30%' }} />
|
||||
<col style={{ width: '18%' }} />
|
||||
<col style={{ width: '20%' }} />
|
||||
<col style={{ width: '14%' }} />
|
||||
<col style={{ width: '10%' }} />
|
||||
<col style={{ width: '12%' }} />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<Th>Name</Th>
|
||||
<Th>Staff</Th>
|
||||
<Th>Role</Th>
|
||||
<Th>Mobile</Th>
|
||||
<Th>Shift</Th>
|
||||
<Th>Status</Th>
|
||||
<Th>Till sign-in</Th>
|
||||
<Th>Access</Th>
|
||||
<Th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{paged.rows.map((person) => (
|
||||
<tr key={person.user_id}>
|
||||
<Td isStrong>{person.full_name || '—'}</Td>
|
||||
<Td isStrong>
|
||||
<span style={{ display: 'block' }}>{person.full_name || '—'}</span>
|
||||
{/* Both ride here rather than holding columns: the
|
||||
mobile is filled on half the accounts and a shift on
|
||||
none of them. */}
|
||||
{person.contactno ? (
|
||||
<span
|
||||
style={{
|
||||
display: 'block',
|
||||
fontSize: 11.5,
|
||||
fontWeight: 400,
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
color: 'var(--color-ink-4)',
|
||||
}}
|
||||
>
|
||||
{person.contactno}
|
||||
</span>
|
||||
) : null}
|
||||
{person.shift_name ? (
|
||||
<span
|
||||
style={{
|
||||
display: 'block',
|
||||
fontSize: 11.5,
|
||||
fontWeight: 400,
|
||||
color: 'var(--color-ink-4)',
|
||||
}}
|
||||
>
|
||||
{person.shift_name}
|
||||
{person.shift_start
|
||||
? ` · ${person.shift_start}–${person.shift_end}`
|
||||
: ''}
|
||||
</span>
|
||||
) : null}
|
||||
</Td>
|
||||
<Td>
|
||||
<RoleChip label={person.role} />
|
||||
</Td>
|
||||
<Td isMuted>
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 12 }}>
|
||||
{person.contactno || '—'}
|
||||
</span>
|
||||
</Td>
|
||||
<Td isMuted>
|
||||
{person.shift_name ? (
|
||||
<>
|
||||
<span style={{ display: 'block' }}>{person.shift_name}</span>
|
||||
<span
|
||||
style={{ display: 'block', fontSize: 11, color: 'var(--color-ink-4)' }}
|
||||
>
|
||||
{person.shift_start}–{person.shift_end}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
'Any'
|
||||
)}
|
||||
<Td>
|
||||
<TillSignIn person={person} />
|
||||
</Td>
|
||||
<Td>
|
||||
<StatusChip status={person.status} />
|
||||
<AccessChip person={person} />
|
||||
</Td>
|
||||
<Td>
|
||||
<Button
|
||||
@@ -153,7 +284,7 @@ export function StoreStaffPage() {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<TablePager paged={paged} label="people" />
|
||||
<TablePager paged={paged} label="people" />
|
||||
</Card>
|
||||
</VStack>
|
||||
)}
|
||||
@@ -176,6 +307,122 @@ export function StoreStaffPage() {
|
||||
);
|
||||
}
|
||||
|
||||
/** Whether this person can work the counter right now. */
|
||||
type Access = 'ready' | 'suspended' | 'unknown';
|
||||
|
||||
function accessOf(person: PosUser): Access {
|
||||
const status = (person.status ?? '').toLowerCase();
|
||||
// A missing status is UNKNOWN, not active. Defaulting it to active painted a
|
||||
// green dot beside an account whose state the API never reported — which is
|
||||
// the one case where somebody most needs to look.
|
||||
if (!status) return 'unknown';
|
||||
if (!status.startsWith('active')) return 'suspended';
|
||||
// No has_password branch: it is true on every account and cannot be false —
|
||||
// see the note at the top of the file.
|
||||
return 'ready';
|
||||
}
|
||||
|
||||
/**
|
||||
* What the cashier types at the till, and the PIN that goes with it.
|
||||
*
|
||||
* One cell, because a handover reads them together — the supervisor says the
|
||||
* username and the PIN in the same breath. `authname` is on every account and
|
||||
* was previously visible nowhere in the console.
|
||||
*
|
||||
* The PIN is masked until asked for. `getposusers` returns it in clear
|
||||
* (measured: "9090" on a live account), which is the backend's decision and not
|
||||
* one this page can fix — but a four-digit credential sitting on screen behind a
|
||||
* supervisor at a busy counter is avoidable, so it is avoided.
|
||||
*/
|
||||
function TillSignIn({ person }: { person: PosUser }) {
|
||||
const [isShown, setShown] = useState(false);
|
||||
const name = person.full_name || 'this cashier';
|
||||
|
||||
return (
|
||||
<VStack gap={0.5}>
|
||||
<span
|
||||
style={{
|
||||
display: 'block',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: 11.5,
|
||||
color: 'var(--color-ink-2)',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
title={person.authname || undefined}
|
||||
>
|
||||
{person.authname || '—'}
|
||||
</span>
|
||||
{person.pin ? (
|
||||
<HStack gap={0.5} align="center" justify="center">
|
||||
<span
|
||||
style={{
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: 11.5,
|
||||
letterSpacing: isShown ? '0.06em' : '0.16em',
|
||||
color: 'var(--color-ink-4)',
|
||||
}}
|
||||
>
|
||||
{isShown ? `PIN ${person.pin}` : 'PIN ••••'}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShown((shown) => !shown)}
|
||||
aria-label={isShown ? `Hide the PIN for ${name}` : `Show the PIN for ${name}`}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
padding: 2,
|
||||
border: 0,
|
||||
borderRadius: 6,
|
||||
background: 'transparent',
|
||||
color: 'var(--color-ink-4)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{isShown ? <EyeOff size={12} /> : <Eye size={12} />}
|
||||
</button>
|
||||
</HStack>
|
||||
) : null}
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One chip for the one question: can this person open the till?
|
||||
*
|
||||
* `status` and `has_password` are both filled on every account, and separately
|
||||
* neither answers it — an Active account with no password is refused by the
|
||||
* till, and reading that off two columns is work the page should have done.
|
||||
*/
|
||||
function AccessChip({ person }: { person: PosUser }) {
|
||||
const access = accessOf(person);
|
||||
|
||||
const look: Record<Access, { label: string; colour: string }> = {
|
||||
ready: { label: 'Ready', colour: 'var(--color-success, #10b981)' },
|
||||
suspended: { label: person.status || 'Suspended', colour: 'var(--color-ink-4)' },
|
||||
unknown: { label: 'Unknown', colour: 'var(--color-ink-4)' },
|
||||
};
|
||||
const { label, colour } = look[access];
|
||||
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 5,
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
color: access === 'ready' ? 'var(--color-ink-2)' : colour,
|
||||
}}
|
||||
>
|
||||
<span style={{ width: 6, height: 6, borderRadius: 999, background: colour, flex: 'none' }} />
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function Note({ title, body }: { title: string; body?: string }) {
|
||||
return (
|
||||
<Card padding={0} elevation="low">
|
||||
@@ -218,32 +465,3 @@ function RoleChip({ label }: { label: string | undefined }) {
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** A dot and a word, not a pill — the merchant's page reads the same way. */
|
||||
function StatusChip({ status }: { status: string | undefined }) {
|
||||
// A missing status is UNKNOWN, not active. Defaulting it to active painted a
|
||||
// green dot and the word "Active" beside an account whose state the API never
|
||||
// reported — which is the one case where somebody most needs to look.
|
||||
const isActive = (status ?? '').toLowerCase().startsWith('active');
|
||||
const isUnknown = !status;
|
||||
const colour = isUnknown
|
||||
? 'var(--color-ink-4)'
|
||||
: isActive
|
||||
? 'var(--color-success, #10b981)'
|
||||
: 'var(--color-ink-4)';
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 5,
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
color: isActive ? 'var(--color-ink-2)' : 'var(--color-ink-4)',
|
||||
}}
|
||||
>
|
||||
<span style={{ width: 6, height: 6, borderRadius: 999, background: colour, flex: 'none' }} />
|
||||
{isUnknown ? 'Unknown' : isActive ? 'Active' : status}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -112,11 +112,25 @@ export function BarAction({
|
||||
);
|
||||
}
|
||||
|
||||
export function Th({ children }: { children?: ReactNode }) {
|
||||
/**
|
||||
* A column heading.
|
||||
*
|
||||
* `align` defaults to centre because every store-user table was built that
|
||||
* way and changing them all is not this prop's job. Pass `"end"` for a column
|
||||
* of numbers: a figure is read against the ones above and below it, and that
|
||||
* only works when the digits line up on the right.
|
||||
*/
|
||||
export function Th({
|
||||
children,
|
||||
align = 'center',
|
||||
}: {
|
||||
children?: ReactNode;
|
||||
align?: 'start' | 'center' | 'end';
|
||||
}) {
|
||||
return (
|
||||
<th
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
textAlign: align,
|
||||
padding: '10px',
|
||||
borderBottom: '1px solid var(--color-line)',
|
||||
fontSize: 12,
|
||||
@@ -134,15 +148,17 @@ export function Td({
|
||||
children,
|
||||
isMuted,
|
||||
isStrong,
|
||||
align = 'center',
|
||||
}: {
|
||||
children: ReactNode;
|
||||
isMuted?: boolean;
|
||||
isStrong?: boolean;
|
||||
align?: 'start' | 'center' | 'end';
|
||||
}) {
|
||||
return (
|
||||
<td
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
textAlign: align,
|
||||
padding: '10px',
|
||||
borderBottom: '1px solid color-mix(in oklab, var(--color-line) 55%, transparent)',
|
||||
color: isMuted ? 'var(--color-ink-3)' : 'var(--color-ink-1)',
|
||||
|
||||
@@ -546,11 +546,23 @@ export function useStaff(tenantid: number | undefined) {
|
||||
* Fanned out because `getposusers` takes a single `locationid` — the same
|
||||
* constraint as every other POS read.
|
||||
*/
|
||||
export function usePosUsersByBranch(tenantid: number | undefined, locationIds: readonly number[]) {
|
||||
/**
|
||||
* Till accounts, per branch.
|
||||
*
|
||||
* `includeInactive` is opt-in and part of the cache key, so a page that wants
|
||||
* to keep a switched-off cashier on screen gets them without changing what
|
||||
* every other caller sees — see `posUsersApi.list` for why the default stayed
|
||||
* as it was.
|
||||
*/
|
||||
export function usePosUsersByBranch(
|
||||
tenantid: number | undefined,
|
||||
locationIds: readonly number[],
|
||||
includeInactive = false,
|
||||
) {
|
||||
return useQueries({
|
||||
queries: locationIds.map((locationid) => ({
|
||||
queryKey: queryKeys.people.posUsers(tenantid ?? 0, locationid),
|
||||
queryFn: () => posUsersApi.list(tenantid as number, locationid),
|
||||
queryKey: [...queryKeys.people.posUsers(tenantid ?? 0, locationid), includeInactive] as const,
|
||||
queryFn: () => posUsersApi.list(tenantid as number, locationid, includeInactive),
|
||||
enabled: Boolean(tenantid),
|
||||
...stable,
|
||||
})),
|
||||
|
||||
Reference in New Issue
Block a user