This commit is contained in:
2026-09-15 20:51:01 +05:30
parent b2104d21b0
commit 98398894a5
14 changed files with 1688 additions and 619 deletions

View File

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

View File

@@ -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 : [])),

View File

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

View File

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

View File

@@ -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&apos;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&apos;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>

View File

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

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

View File

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

View File

@@ -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 '—';

View File

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

View File

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

View File

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

View File

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

View File

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