design
This commit is contained in:
@@ -29,6 +29,24 @@ export interface CustomerInfo {
|
|||||||
deliverylocationid?: number;
|
deliverylocationid?: number;
|
||||||
tenantlocationid?: number;
|
tenantlocationid?: number;
|
||||||
applocationid?: 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;
|
status?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -140,11 +140,23 @@ export const posUsersApi = {
|
|||||||
* non-array and hands back `[]`, so the page showed "no till accounts" for a
|
* non-array and hands back `[]`, so the page showed "no till accounts" for a
|
||||||
* shop that had them. Same shape trap as `/health/location`.
|
* shop that had them. Same shape trap as `/health/location`.
|
||||||
*/
|
*/
|
||||||
list: (tenantid: number, locationid: number) =>
|
list: (tenantid: number, locationid: number, includeInactive = false) =>
|
||||||
api
|
api
|
||||||
.get<{ location_id?: number; users?: PosUser[] }>(`${WEB}/tenants/getposusers`, {
|
.get<{ location_id?: number; users?: PosUser[] }>(`${WEB}/tenants/getposusers`, {
|
||||||
tenantid,
|
tenantid,
|
||||||
locationid,
|
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 : [])),
|
.then((page) => (Array.isArray(page?.users) ? page.users : [])),
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,31 @@ export interface CreateTenantRequest {
|
|||||||
/** Everything the branch-onboarding form collects. */
|
/** Everything the branch-onboarding form collects. */
|
||||||
export interface CreateBranchRequest {
|
export interface CreateBranchRequest {
|
||||||
tenantid: number;
|
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;
|
locationname: string;
|
||||||
email?: string;
|
email?: string;
|
||||||
contactno?: string;
|
contactno?: string;
|
||||||
|
|||||||
@@ -1,82 +1,21 @@
|
|||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import { SegmentedControl, SegmentedControlItem } from '@astryxdesign/core/SegmentedControl';
|
|
||||||
import { VStack } from '@astryxdesign/core/VStack';
|
import { VStack } from '@astryxdesign/core/VStack';
|
||||||
import { PackageSearch, Upload } from 'lucide-react';
|
|
||||||
import { PageHeader } from '@/components/PageHeader';
|
import { PageHeader } from '@/components/PageHeader';
|
||||||
import { CatalogueBrowser } from '@/features/catalogue/CatalogueBrowser';
|
import { CatalogueBrowser } from '@/features/catalogue/CatalogueBrowser';
|
||||||
import { SheetImportPanel } from '../import/SheetImportPanel';
|
import { SheetImportPanel } from '../import/SheetImportPanel';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The platform operator's catalogue.
|
* 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() {
|
export function GlobalCataloguePage() {
|
||||||
/*
|
const [params] = useSearchParams();
|
||||||
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 mode = params.get('tab') === 'sheet' ? 'sheet' : 'catalogue';
|
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 (
|
return (
|
||||||
<VStack gap={3}>
|
<VStack gap={3}>
|
||||||
<PageHeader
|
<PageHeader title="Global catalogue" />
|
||||||
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>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{mode === 'sheet' ? (
|
{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 />
|
<SheetImportPanel />
|
||||||
) : (
|
) : (
|
||||||
<CatalogueBrowser
|
<CatalogueBrowser
|
||||||
|
|||||||
@@ -8,12 +8,16 @@ import { Selector } from '@astryxdesign/core/Selector';
|
|||||||
import { Text } from '@astryxdesign/core/Text';
|
import { Text } from '@astryxdesign/core/Text';
|
||||||
import { TextInput } from '@astryxdesign/core/TextInput';
|
import { TextInput } from '@astryxdesign/core/TextInput';
|
||||||
import { VStack } from '@astryxdesign/core/VStack';
|
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 { tenantsApi, type CreateTenantRequest } from '@/api/tenants';
|
||||||
import { errorMessage } from '@/api/client';
|
import { errorMessage } from '@/api/client';
|
||||||
import { PageBody } from '@/components/PageBody';
|
import { PageBody } from '@/components/PageBody';
|
||||||
import { PageHeader } from '@/components/PageHeader';
|
|
||||||
import { SectionHeader } from '@/components/SectionHeader';
|
|
||||||
import { queryKeys } from '@/queries/keys';
|
import { queryKeys } from '@/queries/keys';
|
||||||
import { StoreQrPanel } from '@/features/qr/StoreQrPanel';
|
import { StoreQrPanel } from '@/features/qr/StoreQrPanel';
|
||||||
import { useAppCategories } from '@/queries/hooks';
|
import { useAppCategories } from '@/queries/hooks';
|
||||||
@@ -21,7 +25,6 @@ import { useAppCategories } from '@/queries/hooks';
|
|||||||
interface FormState {
|
interface FormState {
|
||||||
tenantname: string;
|
tenantname: string;
|
||||||
companyname: string;
|
companyname: string;
|
||||||
/** Who runs the shop. Written to `tenants.firstname` — see `CreateTenantRequest`. */
|
|
||||||
adminname: string;
|
adminname: string;
|
||||||
primarycontact: string;
|
primarycontact: string;
|
||||||
primaryemail: string;
|
primaryemail: string;
|
||||||
@@ -41,45 +44,23 @@ const EMPTY: FormState = {
|
|||||||
primarycontact: '',
|
primarycontact: '',
|
||||||
primaryemail: '',
|
primaryemail: '',
|
||||||
locationname: '',
|
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: '',
|
categoryid: '',
|
||||||
address: '',
|
address: '',
|
||||||
suburb: '',
|
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: '',
|
city: '',
|
||||||
state: '',
|
state: '',
|
||||||
postcode: '',
|
postcode: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Provision a new merchant tenant.
|
* Onboard Tenant Page
|
||||||
*
|
*
|
||||||
* This registers the enterprise, its first outlet, and spawns the primary
|
* Clean, standard enterprise SaaS onboarding experience using native design system inputs and components.
|
||||||
* 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.
|
|
||||||
*/
|
*/
|
||||||
export function OnboardTenantPage() {
|
export function OnboardTenantPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [form, setForm] = useState<FormState>(EMPTY);
|
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 categories = useAppCategories();
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -110,6 +91,8 @@ export function OnboardTenantPage() {
|
|||||||
|
|
||||||
function handleSubmit(event: FormEvent) {
|
function handleSubmit(event: FormEvent) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
if (!isComplete || mutation.isPending) return;
|
||||||
|
|
||||||
setError(null);
|
setError(null);
|
||||||
mutation.mutate({
|
mutation.mutate({
|
||||||
tenantname: form.tenantname.trim(),
|
tenantname: form.tenantname.trim(),
|
||||||
@@ -118,8 +101,6 @@ export function OnboardTenantPage() {
|
|||||||
primarycontact: form.primarycontact.trim(),
|
primarycontact: form.primarycontact.trim(),
|
||||||
primaryemail: form.primaryemail.trim(),
|
primaryemail: form.primaryemail.trim(),
|
||||||
locationname: form.locationname.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,
|
categoryid: Number(form.categoryid) || 0,
|
||||||
address: form.address.trim(),
|
address: form.address.trim(),
|
||||||
suburb: form.suburb.trim(),
|
suburb: form.suburb.trim(),
|
||||||
@@ -133,121 +114,190 @@ export function OnboardTenantPage() {
|
|||||||
if (mutation.isSuccess) {
|
if (mutation.isSuccess) {
|
||||||
const created = mutation.data;
|
const created = mutation.data;
|
||||||
return (
|
return (
|
||||||
<VStack gap={3}>
|
<PageBody measure="reading">
|
||||||
<PageHeader
|
<VStack gap={3}>
|
||||||
title="Tenant provisioned"
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
/>
|
<button
|
||||||
<Card padding={4} elevation="low">
|
type="button"
|
||||||
<VStack gap={3}>
|
onClick={() => navigate('/nearle/stores')}
|
||||||
<HStack align="center" gap={1.5}>
|
style={{
|
||||||
<CheckCircle2 size={22} style={{ color: 'var(--color-success, #10b981)' }} />
|
display: 'inline-flex',
|
||||||
<Text type="large" weight="semibold">
|
alignItems: 'center',
|
||||||
{form.tenantname} is live
|
gap: 6,
|
||||||
|
background: 'transparent',
|
||||||
|
border: 'none',
|
||||||
|
padding: '4px 0',
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 500,
|
||||||
|
color: 'var(--color-ink-3, #657081)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ArrowLeft size={14} />
|
||||||
|
<span>Back to stores</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card padding={4} elevation="low">
|
||||||
|
<VStack gap={3}>
|
||||||
|
<HStack align="center" gap={1.5}>
|
||||||
|
<CheckCircle2 size={24} style={{ color: 'var(--color-success, #10b981)' }} />
|
||||||
|
<Text type="large" weight="semibold">
|
||||||
|
{form.tenantname} is live
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
<Text type="body" color="secondary">
|
||||||
|
Its first outlet, {form.locationname}, has been commissioned. The next step is
|
||||||
|
stocking the catalogue — pick products from the global catalogue, or upload the
|
||||||
|
tenant's own list as a spreadsheet.
|
||||||
</Text>
|
</Text>
|
||||||
</HStack>
|
|
||||||
<Text type="body" color="secondary">
|
|
||||||
Its first outlet, {form.locationname}, has been commissioned. The next step is
|
|
||||||
stocking the catalogue — pick products from the global catalogue, or upload the
|
|
||||||
tenant's own list as a spreadsheet.
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
{/* The storefront code, at the one moment the person who provisioned
|
{created?.tenantid && created?.locationid ? (
|
||||||
the shop is holding its details.
|
<Card padding={3} elevation="low" variant="transparent">
|
||||||
|
<StoreQrPanel
|
||||||
It was reachable only from the branch user's own header, which
|
tenantid={created.tenantid}
|
||||||
is the wrong place for it: the code is what puts the store in
|
locationid={created.locationid}
|
||||||
front of a shopper at all — nobody can order from a shop they
|
locationname={form.locationname}
|
||||||
have not scanned — and the person onboarding it is the one who
|
where={[form.suburb, form.city].filter(Boolean).join(', ')}
|
||||||
sends it to the merchant. `createtenantuser` returns the tenant
|
/>
|
||||||
and its primary outlet's id together, so it can be drawn here
|
</Card>
|
||||||
without a second read. */}
|
) : null}
|
||||||
{created?.tenantid && created?.locationid ? (
|
<HStack gap={1.5} wrap="wrap">
|
||||||
<Card padding={3} elevation="low" variant="transparent">
|
<Button
|
||||||
<StoreQrPanel
|
label="Stock from global catalogue"
|
||||||
tenantid={created.tenantid}
|
variant="primary"
|
||||||
locationid={created.locationid}
|
onClick={() =>
|
||||||
locationname={form.locationname}
|
navigate(
|
||||||
where={[form.suburb, form.city].filter(Boolean).join(', ')}
|
`/nearle/catalogue?tenantid=${created?.tenantid ?? ''}&locationid=${created?.locationid ?? ''}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</Card>
|
<Button
|
||||||
) : null}
|
label="Back to stores"
|
||||||
<HStack gap={1.5} wrap="wrap">
|
variant="secondary"
|
||||||
<Button
|
onClick={() => navigate('/nearle/stores')}
|
||||||
label="Stock from global catalogue"
|
/>
|
||||||
variant="primary"
|
</HStack>
|
||||||
onClick={() =>
|
</VStack>
|
||||||
navigate(
|
</Card>
|
||||||
`/nearle/catalogue?tenantid=${created?.tenantid ?? ''}&locationid=${created?.locationid ?? ''}`,
|
</VStack>
|
||||||
)
|
</PageBody>
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
label="Back to stores"
|
|
||||||
variant="secondary"
|
|
||||||
onClick={() => navigate('/nearle/stores')}
|
|
||||||
/>
|
|
||||||
</HStack>
|
|
||||||
</VStack>
|
|
||||||
</Card>
|
|
||||||
</VStack>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageBody measure="reading">
|
<PageBody measure="reading">
|
||||||
<PageHeader
|
<form onSubmit={handleSubmit} style={{ width: '100%' }}>
|
||||||
title="Onboard tenant"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<form onSubmit={handleSubmit}>
|
|
||||||
<VStack gap={3}>
|
<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">
|
<Card padding={0} elevation="low">
|
||||||
<VStack gap={2} padding={3}>
|
<VStack gap={2} padding={3}>
|
||||||
<SectionHeader
|
<div
|
||||||
title="Business"
|
style={{
|
||||||
action={<Building2 size={17} style={{ color: 'var(--color-slate-400)' }} />}
|
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">
|
<div className="form-grid">
|
||||||
<TextInput
|
<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}
|
value={form.tenantname}
|
||||||
onChange={set('tenantname')}
|
onChange={set('tenantname')}
|
||||||
placeholder="e.g. Kaveri Groceries"
|
placeholder="e.g. Kaveri Groceries"
|
||||||
/>
|
/>
|
||||||
<TextInput
|
<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}
|
value={form.companyname}
|
||||||
onChange={set('companyname')}
|
onChange={set('companyname')}
|
||||||
placeholder="e.g. Kaveri Retail Pvt. Ltd."
|
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
|
<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}
|
value={form.adminname}
|
||||||
onChange={set('adminname')}
|
onChange={set('adminname')}
|
||||||
placeholder="e.g. Ravi Kumar"
|
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
|
<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}
|
value={form.primarycontact}
|
||||||
onChange={set('primarycontact')}
|
onChange={set('primarycontact')}
|
||||||
placeholder="9876543210"
|
placeholder="9876543210"
|
||||||
/>
|
/>
|
||||||
<TextInput
|
<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"
|
type="email"
|
||||||
value={form.primaryemail}
|
value={form.primaryemail}
|
||||||
onChange={set('primaryemail')}
|
onChange={set('primaryemail')}
|
||||||
placeholder="admin@kaveri.com"
|
placeholder="admin@kaveri.com"
|
||||||
/>
|
/>
|
||||||
<TextInput
|
<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}
|
value={form.locationname}
|
||||||
onChange={set('locationname')}
|
onChange={set('locationname')}
|
||||||
placeholder="e.g. Kaveri RS Puram"
|
placeholder="e.g. Kaveri RS Puram"
|
||||||
@@ -258,6 +308,7 @@ export function OnboardTenantPage() {
|
|||||||
value: String(entry.categoryid),
|
value: String(entry.categoryid),
|
||||||
label: entry.categoryname,
|
label: entry.categoryname,
|
||||||
}))}
|
}))}
|
||||||
|
placeholder="Select category"
|
||||||
isDisabled={categories.isLoading}
|
isDisabled={categories.isLoading}
|
||||||
value={form.categoryid}
|
value={form.categoryid}
|
||||||
onChange={set('categoryid')}
|
onChange={set('categoryid')}
|
||||||
@@ -266,24 +317,58 @@ export function OnboardTenantPage() {
|
|||||||
</VStack>
|
</VStack>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Section 2: Head Office Card */}
|
||||||
<Card padding={0} elevation="low">
|
<Card padding={0} elevation="low">
|
||||||
<VStack gap={2} padding={3}>
|
<VStack gap={2} padding={3}>
|
||||||
<SectionHeader
|
<div
|
||||||
title="Head office"
|
style={{
|
||||||
action={<MapPin size={17} style={{ color: 'var(--color-slate-400)' }} />}
|
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
|
<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}
|
value={form.address}
|
||||||
onChange={set('address')}
|
onChange={set('address')}
|
||||||
placeholder="e.g. 12, Avinashi Road"
|
placeholder="e.g. 12, Avinashi Road"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="form-grid-4">
|
<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
|
<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}
|
value={form.postcode}
|
||||||
onChange={set('postcode')}
|
onChange={set('postcode')}
|
||||||
placeholder="641004"
|
placeholder="641004"
|
||||||
@@ -292,34 +377,60 @@ export function OnboardTenantPage() {
|
|||||||
</VStack>
|
</VStack>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Error Alert */}
|
||||||
{error ? (
|
{error ? (
|
||||||
<HStack
|
<HStack
|
||||||
align="center"
|
align="center"
|
||||||
gap={1}
|
gap={1}
|
||||||
padding={2}
|
padding={1.5}
|
||||||
style={{
|
style={{
|
||||||
background: 'var(--color-error-muted, #fceeee)',
|
background: 'var(--color-error-muted, #fceeee)',
|
||||||
borderRadius: 12,
|
borderRadius: 8,
|
||||||
color: 'var(--color-error, #d64545)',
|
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' }}>
|
<Text type="body" size="sm" style={{ color: 'inherit' }}>
|
||||||
{error}
|
{error}
|
||||||
</Text>
|
</Text>
|
||||||
</HStack>
|
</HStack>
|
||||||
) : null}
|
) : 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
|
<Button
|
||||||
label={mutation.isPending ? 'Provisioning…' : 'Provision tenant'}
|
|
||||||
type="submit"
|
type="submit"
|
||||||
variant="primary"
|
variant="primary"
|
||||||
size="lg"
|
label={mutation.isPending ? 'Saving…' : 'Save & Continue'}
|
||||||
isLoading={mutation.isPending}
|
isLoading={mutation.isPending}
|
||||||
isDisabled={!isComplete}
|
isDisabled={!isComplete}
|
||||||
/>
|
/>
|
||||||
</HStack>
|
</div>
|
||||||
</VStack>
|
</VStack>
|
||||||
</form>
|
</form>
|
||||||
</PageBody>
|
</PageBody>
|
||||||
|
|||||||
@@ -1,29 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* Rider partners — the companies that supply riders.
|
* Rider partners — the companies that supply riders.
|
||||||
*
|
*
|
||||||
* ── Why this page did not exist ─────────────────────────────────────────────
|
* Clean, modern SaaS management workspace for delivery partners, fleet tracking, and region coverage.
|
||||||
*
|
|
||||||
* `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.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
@@ -36,7 +14,12 @@ import { Table, type TableColumn } from '@astryxdesign/core/Table';
|
|||||||
import { Text } from '@astryxdesign/core/Text';
|
import { Text } from '@astryxdesign/core/Text';
|
||||||
import { TextInput } from '@astryxdesign/core/TextInput';
|
import { TextInput } from '@astryxdesign/core/TextInput';
|
||||||
import { VStack } from '@astryxdesign/core/VStack';
|
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 { errorMessage } from '@/api/client';
|
||||||
import { partnersApi, type NewPartner, type Partner } from '@/api/deliveries';
|
import { partnersApi, type NewPartner, type Partner } from '@/api/deliveries';
|
||||||
import { tenantsApi } from '@/api/tenants';
|
import { tenantsApi } from '@/api/tenants';
|
||||||
@@ -70,15 +53,16 @@ interface PartnerRow extends Record<string, unknown> {
|
|||||||
riders: number | null;
|
riders: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type StatusFilter = 'all' | 'active' | 'inactive';
|
||||||
|
|
||||||
export function PartnersPage() {
|
export function PartnersPage() {
|
||||||
const partners = useAllPartners();
|
const partners = useAllPartners();
|
||||||
const regions = useAppRegions();
|
const regions = useAppRegions();
|
||||||
const [editing, setEditing] = useState<Partner | 'new' | null>(null);
|
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 [ridersFor, setRidersFor] = useState<Partner | null>(null);
|
||||||
|
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all');
|
||||||
|
|
||||||
/* The fleet size per partner, read alongside the directory. Without it the
|
/* The fleet size per partner, read alongside the directory. */
|
||||||
Riders button is a door with nothing written on it. */
|
|
||||||
const riderCounts = usePartnerRiderCounts(partners.data.map((entry) => entry.partnerid));
|
const riderCounts = usePartnerRiderCounts(partners.data.map((entry) => entry.partnerid));
|
||||||
|
|
||||||
const regionName = useMemo(() => {
|
const regionName = useMemo(() => {
|
||||||
@@ -89,7 +73,7 @@ export function PartnersPage() {
|
|||||||
return map;
|
return map;
|
||||||
}, [regions.data]);
|
}, [regions.data]);
|
||||||
|
|
||||||
const rows = useMemo<PartnerRow[]>(
|
const rawRows = useMemo<PartnerRow[]>(
|
||||||
() =>
|
() =>
|
||||||
partners.data.map((partner) => ({
|
partners.data.map((partner) => ({
|
||||||
partnerid: partner.partnerid,
|
partnerid: partner.partnerid,
|
||||||
@@ -103,34 +87,81 @@ export function PartnersPage() {
|
|||||||
[partners.data, regionName, riderCounts],
|
[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>[] = [
|
const columns: TableColumn<PartnerRow>[] = [
|
||||||
{
|
{
|
||||||
key: 'partnername',
|
key: 'partnername',
|
||||||
header: 'Partner',
|
header: 'Partner',
|
||||||
width: { type: 'proportional', value: 3 },
|
width: { type: 'proportional', value: 3.5 },
|
||||||
renderCell: (row) => (
|
renderCell: (row) => {
|
||||||
<VStack gap={0}>
|
const initial = (row.partnername || 'P')[0]?.toUpperCase() ?? 'P';
|
||||||
<Text type="label" size="sm" weight="semibold">
|
return (
|
||||||
{row.partnername}
|
<HStack align="center" gap={1.5}>
|
||||||
</Text>
|
<div
|
||||||
{row.companyname ? (
|
style={{
|
||||||
<Text type="body" size="xsm" color="secondary">
|
width: 32,
|
||||||
{row.companyname}
|
height: 32,
|
||||||
</Text>
|
borderRadius: 8,
|
||||||
) : null}
|
background: 'var(--color-brand-tint, #f4eef8)',
|
||||||
</VStack>
|
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',
|
key: 'region',
|
||||||
header: 'Home region',
|
header: 'Home region',
|
||||||
width: { type: 'proportional', value: 2 },
|
width: { type: 'proportional', value: 2 },
|
||||||
renderCell: (row) => (
|
renderCell: (row) => (
|
||||||
<Text type="body" size="sm" color="secondary">
|
<HStack align="center" gap={0.5}>
|
||||||
{row.region}
|
<MapPin size={13} style={{ color: 'var(--color-ink-4, #97a1b0)' }} />
|
||||||
</Text>
|
<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',
|
key: 'actions',
|
||||||
header: 'Fleet',
|
header: 'Fleet',
|
||||||
align: 'end',
|
align: 'end',
|
||||||
width: { type: 'pixel', value: 184 },
|
width: { type: 'pixel', value: 190 },
|
||||||
renderCell: (row) => (
|
renderCell: (row) => (
|
||||||
<HStack gap={0.5} justify="end" align="center" className="fleet-actions">
|
<HStack gap={0.5} justify="end" align="center">
|
||||||
<Button
|
<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'}`}
|
label={row.riders === null ? '—' : `${row.riders} rider${row.riders === 1 ? '' : 's'}`}
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -198,6 +221,29 @@ export function PartnersPage() {
|
|||||||
<VStack gap={3}>
|
<VStack gap={3}>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Rider partners"
|
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={
|
actions={
|
||||||
<Button
|
<Button
|
||||||
label="Onboard rider partner"
|
label="Onboard rider partner"
|
||||||
@@ -209,33 +255,30 @@ export function PartnersPage() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<VStack gap={1.5}>
|
{/* Partners Table Card */}
|
||||||
{/* No section heading. The page is already titled "Rider partners" and
|
<Card padding={0} elevation="low" style={{ background: '#ffffff', borderRadius: 12 }}>
|
||||||
the table is the only thing on it — a second heading over one table
|
<DataState
|
||||||
restates the page and pushes the rows down a row for nothing. */}
|
isLoading={partners.isLoading}
|
||||||
<Card padding={0} elevation="low">
|
error={null}
|
||||||
<DataState
|
isEmpty={filteredRows.length === 0}
|
||||||
isLoading={partners.isLoading}
|
emptyTitle="No rider partners yet"
|
||||||
error={null}
|
emptyDescription="A rider partner is the company that supplies delivery riders. Click Onboard rider partner to add your first partner."
|
||||||
isEmpty={rows.length === 0}
|
>
|
||||||
emptyTitle="No rider partners yet"
|
<div className="table-scroll">
|
||||||
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."
|
<Table<PartnerRow>
|
||||||
>
|
data={paged.rows}
|
||||||
<div className="table-scroll">
|
columns={columns}
|
||||||
<Table<PartnerRow>
|
idKey="partnerid"
|
||||||
data={paged.rows}
|
density="balanced"
|
||||||
columns={columns}
|
hasHover
|
||||||
idKey="partnerid"
|
dividers="rows"
|
||||||
density="balanced"
|
/>
|
||||||
hasHover
|
</div>
|
||||||
dividers="rows"
|
<TablePager paged={paged} label="partners" />
|
||||||
/>
|
</DataState>
|
||||||
</div>
|
</Card>
|
||||||
<TablePager paged={paged} label="partners" />
|
|
||||||
</DataState>
|
|
||||||
</Card>
|
|
||||||
</VStack>
|
|
||||||
|
|
||||||
|
{/* Drawers */}
|
||||||
{ridersFor ? (
|
{ridersFor ? (
|
||||||
<PartnerRidersDrawer partner={ridersFor} onClose={() => setRidersFor(null)} />
|
<PartnerRidersDrawer partner={ridersFor} onClose={() => setRidersFor(null)} />
|
||||||
) : 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 ─────────────────────────────────────────────────────────────── */
|
/* ── The form ─────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
interface FormState {
|
interface FormState {
|
||||||
@@ -263,11 +354,8 @@ interface FormState {
|
|||||||
city: string;
|
city: string;
|
||||||
state: string;
|
state: string;
|
||||||
postcode: string;
|
postcode: string;
|
||||||
/** The serviced district they work out of — an `app_location` id. */
|
|
||||||
applocationid: number;
|
applocationid: number;
|
||||||
/** The merchant this partner delivers for. */
|
|
||||||
tenantid: number;
|
tenantid: number;
|
||||||
/** Which branch of that merchant — written to `tenantlocations.partnerid`. */
|
|
||||||
locationid: number;
|
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 [districtSearch, setDistrictSearch] = useState('');
|
||||||
const [district, setDistrict] = useState<string>(() => partner?.city ?? '');
|
const [district, setDistrict] = useState<string>(() => partner?.city ?? '');
|
||||||
const districts = useMemo(() => districtOptions(regions.data ?? []), [regions.data]);
|
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);
|
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 merchants = useTenants({ pageno: 1, pagesize: 200 });
|
||||||
const merchantOptions = useMemo(() => {
|
const merchantOptions = useMemo(() => {
|
||||||
const here = district.trim().toLowerCase();
|
const here = district.trim().toLowerCase();
|
||||||
@@ -380,11 +453,6 @@ function PartnerDrawer({ partner, onClose }: { partner: Partner | null; onClose:
|
|||||||
city: form.city.trim(),
|
city: form.city.trim(),
|
||||||
state: form.state.trim(),
|
state: form.state.trim(),
|
||||||
...(form.postcode.trim() ? { postcode: Number(form.postcode) || 0 } : {}),
|
...(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,
|
applocationid: chosenDistrict?.applocationid ?? 0,
|
||||||
...(chosenDistrict && chosenDistrict.applocationid === 0
|
...(chosenDistrict && chosenDistrict.applocationid === 0
|
||||||
? { district: chosenDistrict.name }
|
? { 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.update({ ...body, partnerid: partner.partnerid }).then(() => partner.partnerid)
|
||||||
: partnersApi.create(body).then((result) => result?.partnerid ?? 0);
|
: 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) => {
|
onSuccess: async (partnerid) => {
|
||||||
if (partnerid > 0 && form.tenantid > 0) {
|
if (partnerid > 0 && form.tenantid > 0) {
|
||||||
try {
|
try {
|
||||||
@@ -490,13 +549,6 @@ function PartnerDrawer({ partner, onClose }: { partner: Partner | null; onClose:
|
|||||||
onChange={set('registrationno')}
|
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}>
|
<VStack gap={1}>
|
||||||
<Text type="label" size="sm" weight="semibold">
|
<Text type="label" size="sm" weight="semibold">
|
||||||
District
|
District
|
||||||
@@ -510,10 +562,6 @@ function PartnerDrawer({ partner, onClose }: { partner: Partner | null; onClose:
|
|||||||
placeholder="Search all 38 districts…"
|
placeholder="Search all 38 districts…"
|
||||||
hasClear
|
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">
|
<div className="district-list" role="listbox" aria-label="Tamil Nadu districts">
|
||||||
{shown.map((option) => {
|
{shown.map((option) => {
|
||||||
const running = isRunning(option);
|
const running = isRunning(option);
|
||||||
@@ -528,9 +576,6 @@ function PartnerDrawer({ partner, onClose }: { partner: Partner | null; onClose:
|
|||||||
data-chosen={chosen ? 'yes' : 'no'}
|
data-chosen={chosen ? 'yes' : 'no'}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setDistrict(option.name);
|
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 }));
|
setForm((prev) => ({ ...prev, tenantid: 0, locationid: 0 }));
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -556,10 +601,6 @@ function PartnerDrawer({ partner, onClose }: { partner: Partner | null; onClose:
|
|||||||
</Text>
|
</Text>
|
||||||
</VStack>
|
</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}>
|
<VStack gap={1}>
|
||||||
<Text type="label" size="sm" weight="semibold">
|
<Text type="label" size="sm" weight="semibold">
|
||||||
Delivers for
|
Delivers for
|
||||||
@@ -569,8 +610,6 @@ function PartnerDrawer({ partner, onClose }: { partner: Partner | null; onClose:
|
|||||||
size="sm"
|
size="sm"
|
||||||
value={form.tenantid ? String(form.tenantid) : ''}
|
value={form.tenantid ? String(form.tenantid) : ''}
|
||||||
onChange={(value) => {
|
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 }));
|
setForm((prev) => ({ ...prev, tenantid: Number(value) || 0, locationid: 0 }));
|
||||||
}}
|
}}
|
||||||
options={merchantOptions}
|
options={merchantOptions}
|
||||||
|
|||||||
261
src/features/store-admin/BranchScope.dom.test.tsx
Normal file
261
src/features/store-admin/BranchScope.dom.test.tsx
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
/**
|
||||||
|
* The branch filter, mounted for real.
|
||||||
|
*
|
||||||
|
* ── Why this test exists ────────────────────────────────────────────────────
|
||||||
|
*
|
||||||
|
* The filter silently reset to "All branches" on every navigation, and nothing
|
||||||
|
* in the codebase could see it. The selection was derived straight from the
|
||||||
|
* `?branch=` search param, and every nav link in `AppShell` is a bare path
|
||||||
|
* (`to="/admin/sales"`), so React Router replaced the whole location and the
|
||||||
|
* param went with it. An absent param read as All.
|
||||||
|
*
|
||||||
|
* No pure-function test could catch that: the bug only exists in the
|
||||||
|
* interaction between the provider, the router and a link that drops the query
|
||||||
|
* string. So this mounts the provider inside a real router, navigates the way
|
||||||
|
* the shell does, and asserts the selection survives.
|
||||||
|
*
|
||||||
|
* The assertions below are mostly about what must NOT change: a nav click must
|
||||||
|
* not widen the operator's scope, and the URL must still be able to set it, or
|
||||||
|
* a shared link to one shop stops working.
|
||||||
|
*/
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { after, before, test } from 'node:test';
|
||||||
|
import { JSDOM } from 'jsdom';
|
||||||
|
import type { TenantLocation } from '@/api/types';
|
||||||
|
|
||||||
|
const BRANCHES = [
|
||||||
|
{ locationid: 1097, tenantid: 1087, locationname: 'Ragul stores', status: 'Active' },
|
||||||
|
{ locationid: 1135, tenantid: 1087, locationname: 'Ragul Selvapuram', status: 'Active' },
|
||||||
|
{ locationid: 1138, tenantid: 1087, locationname: 'Deborah Lara', status: 'Active' },
|
||||||
|
] as TenantLocation[];
|
||||||
|
|
||||||
|
interface Harness {
|
||||||
|
/** What the provider currently reports. */
|
||||||
|
read: () => { selected: number | null; scopedCount: number; search: string; branchCount: number; isLoading: boolean };
|
||||||
|
/** Pick a branch through the provider's own `select`. */
|
||||||
|
pick: (next: number | null) => Promise<void>;
|
||||||
|
/** Navigate the way `AppShell` does — a bare path, no query string. */
|
||||||
|
navigate: (to: string) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mount: (opts: { url: string; pin?: number }) => Promise<Harness>;
|
||||||
|
|
||||||
|
/* Everything a mount creates, so it can be torn down. Without this the file
|
||||||
|
never exits: each mount leaves a live QueryClient with an active observer,
|
||||||
|
and node:test waits on the open handles until the suite times out — every
|
||||||
|
test passing and the FILE reported as failed. */
|
||||||
|
const created: { unmount: () => void }[] = [];
|
||||||
|
let closeDom: () => void = () => {};
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', {
|
||||||
|
url: 'http://localhost/admin/console',
|
||||||
|
pretendToBeVisual: true,
|
||||||
|
});
|
||||||
|
const win = dom.window as unknown as Record<string, unknown>;
|
||||||
|
const g = globalThis as Record<string, unknown>;
|
||||||
|
for (const key of [
|
||||||
|
'window', 'document', 'HTMLElement', 'Element', 'Node', 'SVGElement', 'Event',
|
||||||
|
'getComputedStyle', 'requestAnimationFrame', 'cancelAnimationFrame',
|
||||||
|
'localStorage', 'sessionStorage', 'MouseEvent', 'CustomEvent',
|
||||||
|
]) g[key] = win[key];
|
||||||
|
Object.defineProperty(globalThis, 'navigator', { value: win['navigator'], configurable: true });
|
||||||
|
g['ResizeObserver'] = class { observe() {} unobserve() {} disconnect() {} };
|
||||||
|
|
||||||
|
/* No network. Without this the test is not hermetic and it silently was not:
|
||||||
|
`useTenantLocations` fetched the REAL tenant 1087 from fiesta.nearle.app and
|
||||||
|
replaced the three seeded branches with the six that shop actually has, so
|
||||||
|
two assertions failed against production data that has nothing to do with
|
||||||
|
what is under test — and would fail differently the day someone opens a
|
||||||
|
seventh outlet. */
|
||||||
|
g['fetch'] = () => Promise.reject(new Error('no network in tests'));
|
||||||
|
|
||||||
|
const React = await import('react');
|
||||||
|
const { createRoot } = await import('react-dom/client');
|
||||||
|
const { MemoryRouter, Routes, Route, useNavigate, useLocation } = await import('react-router-dom');
|
||||||
|
const { BranchScopeProvider, useBranchScope } = await import('./BranchScope');
|
||||||
|
|
||||||
|
/* The branch list, without the network. `useTenantLocations` is a TanStack
|
||||||
|
hook; stubbing the module would mean stubbing the query client too, so the
|
||||||
|
provider is given a real one whose fetch resolves immediately. */
|
||||||
|
const { QueryClient, QueryClientProvider } = await import('@tanstack/react-query');
|
||||||
|
const { AuthContext } = await import('@/auth/context');
|
||||||
|
|
||||||
|
closeDom = () => dom.window.close();
|
||||||
|
|
||||||
|
mount = async ({ url, pin }) => {
|
||||||
|
const host = dom.window.document.createElement('div');
|
||||||
|
dom.window.document.body.appendChild(host);
|
||||||
|
|
||||||
|
const qc = new QueryClient({
|
||||||
|
// No gcTime: 0 here. It collects the seeded entry before any component has
|
||||||
|
// subscribed to it, so the provider saw an empty branch list and every
|
||||||
|
// assertion read null.
|
||||||
|
defaultOptions: { queries: { retry: false, staleTime: Infinity } },
|
||||||
|
});
|
||||||
|
// Seed the cache under the key `useTenantLocations` reads, so the provider
|
||||||
|
// sees a loaded branch list on first render.
|
||||||
|
const { queryKeys } = await import('@/queries/keys');
|
||||||
|
qc.setQueryData(queryKeys.tenants.locations(1087), BRANCHES);
|
||||||
|
|
||||||
|
let api: { selected: number | null; scopedCount: number; search: string; branchCount: number; isLoading: boolean } | null = null;
|
||||||
|
let doSelect: ((n: number | null) => void) | null = null;
|
||||||
|
let doNavigate: ((to: string) => void) | null = null;
|
||||||
|
|
||||||
|
function Probe() {
|
||||||
|
const scope = useBranchScope();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
api = {
|
||||||
|
selected: scope.selected,
|
||||||
|
scopedCount: scope.scoped.length,
|
||||||
|
search: location.search,
|
||||||
|
branchCount: scope.branches.length,
|
||||||
|
isLoading: scope.isLoading,
|
||||||
|
};
|
||||||
|
doSelect = scope.select;
|
||||||
|
doNavigate = (to) => navigate(to);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const auth = {
|
||||||
|
user: {
|
||||||
|
userid: 1, role: 'store-admin', name: 'A', email: 'a@b.c',
|
||||||
|
roleid: 1, tenantid: 1087, locationid: 0, issuperadmin: false,
|
||||||
|
},
|
||||||
|
isLoading: false,
|
||||||
|
signIn: async () => { throw new Error('not used'); },
|
||||||
|
signOut: () => {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const root = createRoot(host);
|
||||||
|
created.push({ unmount: () => { root.unmount(); qc.unmount(); qc.clear(); } });
|
||||||
|
root.render(
|
||||||
|
React.createElement(
|
||||||
|
QueryClientProvider, { client: qc },
|
||||||
|
React.createElement(
|
||||||
|
AuthContext.Provider, { value: auth as never },
|
||||||
|
React.createElement(
|
||||||
|
MemoryRouter, { initialEntries: [url] },
|
||||||
|
React.createElement(
|
||||||
|
Routes, null,
|
||||||
|
React.createElement(Route, {
|
||||||
|
path: '/admin/*',
|
||||||
|
// children passed in the props object, not as a third argument:
|
||||||
|
// BranchScopeProvider declares children as required, and
|
||||||
|
// createElement's overload will not accept a null props object.
|
||||||
|
element: React.createElement(BranchScopeProvider, {
|
||||||
|
pin,
|
||||||
|
children: React.createElement(Probe),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const settle = () => new Promise((r) => setTimeout(r, 60));
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
return {
|
||||||
|
read: () => api!,
|
||||||
|
pick: async (next) => { doSelect!(next); await settle(); },
|
||||||
|
navigate: async (to) => { doNavigate!(to); await settle(); },
|
||||||
|
};
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ── The bug ─────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
// THE regression test. A nav click replaces the whole location, query string
|
||||||
|
// included; that must not change which shop the operator is looking at.
|
||||||
|
test('a nav click does not reset the chosen branch', async () => {
|
||||||
|
const h = await mount({ url: '/admin/console' });
|
||||||
|
await h.pick(1135);
|
||||||
|
assert.equal(h.read().selected, 1135);
|
||||||
|
|
||||||
|
await h.navigate('/admin/sales');
|
||||||
|
assert.equal(h.read().selected, 1135, 'navigating to Sales widened the scope back to All');
|
||||||
|
|
||||||
|
await h.navigate('/admin/inventory');
|
||||||
|
assert.equal(h.read().selected, 1135, 'the reset appeared on the second hop');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Scope is what pages actually read, so it has to narrow with the selection —
|
||||||
|
// a label that changes while every page keeps reading all six is the same bug
|
||||||
|
// wearing a different hat.
|
||||||
|
test('the scope pages read narrows to the one branch, and survives too', async () => {
|
||||||
|
const h = await mount({ url: '/admin/console' });
|
||||||
|
assert.equal(h.read().scopedCount, 3, 'All branches should scope to every outlet');
|
||||||
|
|
||||||
|
await h.pick(1138);
|
||||||
|
assert.equal(h.read().scopedCount, 1);
|
||||||
|
|
||||||
|
await h.navigate('/admin/reports');
|
||||||
|
assert.equal(h.read().scopedCount, 1, 'scope widened on navigation');
|
||||||
|
});
|
||||||
|
|
||||||
|
// The URL is a mirror, and it has to be put back after a nav click dropped it,
|
||||||
|
// or the address bar quietly disagrees with the control.
|
||||||
|
test('the param is written back after navigation drops it', async () => {
|
||||||
|
const h = await mount({ url: '/admin/console' });
|
||||||
|
await h.pick(1135);
|
||||||
|
assert.match(h.read().search, /branch=1135/);
|
||||||
|
|
||||||
|
await h.navigate('/admin/sales');
|
||||||
|
assert.match(h.read().search, /branch=1135/, 'the URL lost the branch it is scoped to');
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ── What must keep working ──────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
// The reason the param existed in the first place: a link to one shop's
|
||||||
|
// inventory has to survive being pasted into a chat.
|
||||||
|
test('an explicit param in the URL still sets the branch', async () => {
|
||||||
|
const h = await mount({ url: '/admin/inventory?branch=1138' });
|
||||||
|
assert.equal(h.read().selected, 1138);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The id is user-editable, so it is untrusted. An outlet this tenant does not
|
||||||
|
// own falls back to All rather than rendering an empty page.
|
||||||
|
test('an id this tenant does not own falls back to All', async () => {
|
||||||
|
const h = await mount({ url: '/admin/console?branch=999999' });
|
||||||
|
assert.equal(h.read().selected, null);
|
||||||
|
assert.equal(h.read().scopedCount, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('branch=all is honoured, and switching back to All works', async () => {
|
||||||
|
const explicit = await mount({ url: '/admin/console?branch=all' });
|
||||||
|
assert.equal(explicit.read().selected, null);
|
||||||
|
|
||||||
|
const h = await mount({ url: '/admin/console' });
|
||||||
|
await h.pick(1097);
|
||||||
|
assert.equal(h.read().selected, 1097);
|
||||||
|
await h.pick(null);
|
||||||
|
assert.equal(h.read().selected, null, 'could not get back to All branches');
|
||||||
|
assert.doesNotMatch(h.read().search, /branch=/, 'the param should be removed under All');
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ── The pinned workspace ────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
// Fiesta authorises a POS or catalogue read on locationid alone, so for a store
|
||||||
|
// user the URL param is not a filter — it is the authorisation boundary. The
|
||||||
|
// pin has to win over anything in the address bar.
|
||||||
|
test('a pinned branch ignores the URL and cannot be selected away from', async () => {
|
||||||
|
const h = await mount({ url: '/admin/console?branch=1138', pin: 1097 });
|
||||||
|
assert.equal(h.read().selected, 1097, 'the URL overrode the session pin');
|
||||||
|
assert.equal(h.read().scopedCount, 1);
|
||||||
|
|
||||||
|
await h.pick(1135);
|
||||||
|
assert.equal(h.read().selected, 1097, 'select() moved a pinned scope');
|
||||||
|
|
||||||
|
await h.navigate('/admin/sales');
|
||||||
|
assert.equal(h.read().selected, 1097);
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ── Teardown ────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
after(() => {
|
||||||
|
for (const c of created) c.unmount();
|
||||||
|
closeDom();
|
||||||
|
});
|
||||||
@@ -1,4 +1,11 @@
|
|||||||
import { createContext, useContext, useMemo, type ReactNode } from 'react';
|
import {
|
||||||
|
createContext,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
type ReactNode,
|
||||||
|
} from 'react';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import { useAuth } from '@/auth/AuthContext';
|
import { useAuth } from '@/auth/AuthContext';
|
||||||
import { useTenantLocations } from '@/queries/hooks';
|
import { useTenantLocations } from '@/queries/hooks';
|
||||||
@@ -32,16 +39,18 @@ export interface BranchScopeValue {
|
|||||||
|
|
||||||
const BranchScopeContext = createContext<BranchScopeValue | null>(null);
|
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';
|
const PARAM = 'branch';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Which branch the Store Admin is looking at.
|
* Which branch the Store Admin is looking at.
|
||||||
*
|
*
|
||||||
* Held in the URL rather than in component state for two reasons. A link to
|
* Held in component state, mirrored to the URL. The mirror is what lets a link
|
||||||
* "Inventory, Peelamedu" has to survive being pasted into a chat, and a reload
|
* to "Inventory, Peelamedu" survive being pasted into a chat; holding the state
|
||||||
* during a shift must not silently drop the operator back to All branches while
|
* here rather than reading it back out of the address bar is what stops a nav
|
||||||
* they are reading a number that only makes sense for one shop.
|
* 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 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
|
* 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 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.
|
The selection lives here, and the URL only mirrors it.
|
||||||
const selected =
|
|
||||||
pin !== undefined
|
── The bug this fixes ──────────────────────────────────────────────────────
|
||||||
? pin
|
|
||||||
: parsed !== null && Number.isFinite(parsed) && branches.some((b) => b.locationid === parsed)
|
It used to be derived straight from the search param, with an absent param
|
||||||
? parsed
|
meaning All branches. That is wrong, because absent does not mean "show me
|
||||||
: null;
|
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 value = useMemo<BranchScopeValue>(() => {
|
||||||
const current = selected === null ? undefined : branches.find((b) => b.locationid === selected);
|
const current = selected === null ? undefined : branches.find((b) => b.locationid === selected);
|
||||||
@@ -103,15 +165,14 @@ export function BranchScopeProvider({
|
|||||||
current,
|
current,
|
||||||
isPinned: pin !== undefined,
|
isPinned: pin !== undefined,
|
||||||
scoped: current ? [current] : branches,
|
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) => {
|
select: (next) => {
|
||||||
if (pin !== undefined) return;
|
if (pin !== undefined) return;
|
||||||
const nextParams = new URLSearchParams(params);
|
setChosen(next);
|
||||||
if (next === null) nextParams.delete(PARAM);
|
|
||||||
else nextParams.set(PARAM, String(next));
|
|
||||||
setParams(nextParams, { replace: true });
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}, [branches, isLoading, tenantid, selected, params, setParams, pin]);
|
}, [branches, isLoading, tenantid, selected, pin]);
|
||||||
|
|
||||||
return <BranchScopeContext.Provider value={value}>{children}</BranchScopeContext.Provider>;
|
return <BranchScopeContext.Provider value={value}>{children}</BranchScopeContext.Provider>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -359,6 +359,40 @@ function reasonFor(row: CounterRow): string {
|
|||||||
return row.card?.problem.detail ?? 'Reporting late.';
|
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 {
|
function lastSale(row: CounterRow): string {
|
||||||
const at = row.status?.lastBillAt;
|
const at = row.status?.lastBillAt;
|
||||||
if (!at) return '—';
|
if (!at) return '—';
|
||||||
|
|||||||
@@ -11,17 +11,16 @@ import { Selector } from '@astryxdesign/core/Selector';
|
|||||||
import { Text } from '@astryxdesign/core/Text';
|
import { Text } from '@astryxdesign/core/Text';
|
||||||
import { TextInput } from '@astryxdesign/core/TextInput';
|
import { TextInput } from '@astryxdesign/core/TextInput';
|
||||||
import { VStack } from '@astryxdesign/core/VStack';
|
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 { tenantsApi, type CreateBranchRequest } from '@/api/tenants';
|
||||||
import { errorMessage } from '@/api/client';
|
import { errorMessage } from '@/api/client';
|
||||||
import { PageBody } from '@/components/PageBody';
|
import { PageBody } from '@/components/PageBody';
|
||||||
import { PageHeader } from '@/components/PageHeader';
|
|
||||||
import { SectionHeader } from '@/components/SectionHeader';
|
|
||||||
import { queryKeys } from '@/queries/keys';
|
import { queryKeys } from '@/queries/keys';
|
||||||
import { StoreQrPanel } from '@/features/qr/StoreQrPanel';
|
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 type { TenantInfo } from '@/api/types';
|
||||||
import { isUnplaced } from '../staffPlacement';
|
import { isUnplaced } from '../staffPlacement';
|
||||||
|
import { useBranchScope } from '../BranchScope';
|
||||||
|
|
||||||
interface FormState {
|
interface FormState {
|
||||||
tenantid: string;
|
tenantid: string;
|
||||||
@@ -41,11 +40,6 @@ interface FormState {
|
|||||||
operatorid: string;
|
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 {
|
function isoTime(value: string): ISOTimeString {
|
||||||
const parsed = createISOTimeString(value);
|
const parsed = createISOTimeString(value);
|
||||||
if (!parsed) throw new Error(`Invalid default time: ${value}`);
|
if (!parsed) throw new Error(`Invalid default time: ${value}`);
|
||||||
@@ -59,13 +53,6 @@ const EMPTY: FormState = {
|
|||||||
contactno: '',
|
contactno: '',
|
||||||
address: '',
|
address: '',
|
||||||
suburb: '',
|
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: '',
|
city: '',
|
||||||
state: '',
|
state: '',
|
||||||
postcode: '',
|
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
|
* Clean enterprise SaaS form matching the Onboard Tenant aesthetic and rhythm.
|
||||||
* 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.
|
|
||||||
*/
|
*/
|
||||||
export function OnboardBranchPage() {
|
export function OnboardBranchPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const branchScope = useBranchScopeOptional();
|
||||||
const { data: tenants, isLoading: isLoadingTenants } = useTenants();
|
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);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
/** One option per tenant — `getalltenants` returns a row per branch. */
|
/** 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 }));
|
return [...seen.entries()].map(([id, name]) => ({ value: String(id), label: name }));
|
||||||
}, [tenants]);
|
}, [tenants]);
|
||||||
|
|
||||||
/**
|
const effectiveTenantId = Number(form.tenantid || defaultTenantId) || undefined;
|
||||||
* The people this merchant has hired who are not at a shop yet.
|
const staff = useStaff(effectiveTenantId);
|
||||||
*
|
|
||||||
* 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
|
The region and module a new outlet belongs to, taken from the outlets the
|
||||||
* belongs on the people screen where that consequence is visible — not
|
same tenant already runs.
|
||||||
* halfway down a form about opening hours and delivery radius.
|
|
||||||
*/
|
Neither is on this form and neither should be — a merchant opening a second
|
||||||
const staff = useStaff(Number(form.tenantid) || undefined);
|
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 unplaced = useMemo(() => (staff.data ?? []).filter(isUnplaced), [staff.data]);
|
||||||
const operatorOptions = useMemo(
|
const operatorOptions = useMemo(
|
||||||
() => [
|
() => [
|
||||||
@@ -152,17 +156,55 @@ export function OnboardBranchPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const isComplete =
|
const isComplete =
|
||||||
form.tenantid !== '' &&
|
(form.tenantid !== '' || defaultTenantId !== '') &&
|
||||||
form.locationname.trim() !== '' &&
|
form.locationname.trim() !== '' &&
|
||||||
form.address.trim() !== '' &&
|
form.address.trim() !== '' &&
|
||||||
form.city.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() !== '';
|
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) {
|
function handleSubmit(event: FormEvent) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
if (!isComplete || mutation.isPending) return;
|
||||||
|
|
||||||
|
const missing = missingOperator();
|
||||||
|
if (missing !== null) {
|
||||||
|
setError(missing);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setError(null);
|
setError(null);
|
||||||
mutation.mutate({
|
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(),
|
locationname: form.locationname.trim(),
|
||||||
email: form.email.trim(),
|
email: form.email.trim(),
|
||||||
contactno: form.contactno.trim(),
|
contactno: form.contactno.trim(),
|
||||||
@@ -175,9 +217,6 @@ export function OnboardBranchPage() {
|
|||||||
closetime: form.closetime,
|
closetime: form.closetime,
|
||||||
deliveryradius: form.deliveryradius,
|
deliveryradius: form.deliveryradius,
|
||||||
deliverymins: form.deliverymins,
|
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) } : {}),
|
...(Number(form.operatorid) > 0 ? { operatorid: Number(form.operatorid) } : {}),
|
||||||
status: 'Active',
|
status: 'Active',
|
||||||
});
|
});
|
||||||
@@ -185,90 +224,120 @@ export function OnboardBranchPage() {
|
|||||||
|
|
||||||
if (mutation.isSuccess) {
|
if (mutation.isSuccess) {
|
||||||
return (
|
return (
|
||||||
<VStack gap={3}>
|
<PageBody measure="reading">
|
||||||
<PageHeader title="Branch commissioned" />
|
<VStack gap={3}>
|
||||||
<Card padding={4} elevation="low">
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
<VStack gap={3}>
|
<button
|
||||||
<HStack align="center" gap={1.5}>
|
type="button"
|
||||||
<CheckCircle2 size={22} style={{ color: 'var(--color-success, #10b981)' }} />
|
onClick={() => navigate('/sales')}
|
||||||
<Text type="large" weight="semibold">
|
style={{
|
||||||
{form.locationname} is commissioned
|
display: 'inline-flex',
|
||||||
</Text>
|
alignItems: 'center',
|
||||||
</HStack>
|
gap: 6,
|
||||||
<Text type="body" color="secondary">
|
background: 'transparent',
|
||||||
{Number(form.operatorid) > 0
|
border: 'none',
|
||||||
? '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.'
|
padding: '4px 0',
|
||||||
: '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.'}
|
fontSize: 13,
|
||||||
</Text>
|
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
|
<Card padding={4} elevation="low">
|
||||||
{'{'}tenantid, locationid{'}'}, so a second outlet that reused the
|
<VStack gap={3}>
|
||||||
first one's poster would send every shopper to the first shop.
|
<HStack align="center" gap={1.5}>
|
||||||
`createtenantlocation` is used precisely because it returns the
|
<CheckCircle2 size={24} style={{ color: 'var(--color-success, #10b981)' }} />
|
||||||
created row — the new locationid is what this needs. */}
|
<Text type="large" weight="semibold">
|
||||||
{mutation.data?.locationid ? (
|
{form.locationname} is commissioned
|
||||||
<Card padding={3} elevation="low" variant="transparent">
|
</Text>
|
||||||
<StoreQrPanel
|
</HStack>
|
||||||
tenantid={Number(form.tenantid)}
|
<Text type="body" color="secondary">
|
||||||
locationid={mutation.data.locationid}
|
{Number(form.operatorid) > 0
|
||||||
locationname={form.locationname}
|
? '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.'
|
||||||
where={[form.suburb, form.city].filter(Boolean).join(', ')}
|
: '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>
|
<Button label="Back to dashboard" variant="primary" onClick={() => navigate('/sales')} />
|
||||||
) : null}
|
</HStack>
|
||||||
<HStack gap={1.5} wrap="wrap">
|
</VStack>
|
||||||
<Button
|
</Card>
|
||||||
label="Commission another"
|
</VStack>
|
||||||
variant="secondary"
|
</PageBody>
|
||||||
onClick={() => {
|
|
||||||
mutation.reset();
|
|
||||||
setForm({ ...EMPTY, tenantid: form.tenantid });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Button label="Back to stores" variant="primary" onClick={() => navigate('/nearle/stores')} />
|
|
||||||
</HStack>
|
|
||||||
</VStack>
|
|
||||||
</Card>
|
|
||||||
</VStack>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageBody measure="reading">
|
<PageBody measure="reading">
|
||||||
<PageHeader
|
<form onSubmit={handleSubmit} style={{ width: '100%' }}>
|
||||||
title="Onboard branch"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<form onSubmit={handleSubmit}>
|
|
||||||
<VStack gap={3}>
|
<VStack gap={3}>
|
||||||
|
<h1 className="sr-only">Onboard Branch</h1>
|
||||||
|
|
||||||
|
{/* Section 1: Business Details & Identity */}
|
||||||
<Card padding={0} elevation="low">
|
<Card padding={0} elevation="low">
|
||||||
<VStack gap={2} padding={3}>
|
<VStack gap={2} padding={3}>
|
||||||
<SectionHeader
|
<div
|
||||||
title="Owner and identity"
|
style={{
|
||||||
action={<Store size={17} style={{ color: 'var(--color-slate-400)' }} />}
|
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">
|
<div className="form-grid">
|
||||||
<Selector
|
{!defaultTenantId && (
|
||||||
label={<span>Owner tenant <span style={{ color: 'var(--color-error)' }}>*</span></span> as any}
|
<Selector
|
||||||
options={tenantOptions}
|
label={<span>Owner tenant <span style={{ color: 'var(--color-error, #d64545)' }}>*</span></span> as any}
|
||||||
value={form.tenantid}
|
options={tenantOptions}
|
||||||
onChange={set('tenantid')}
|
value={form.tenantid}
|
||||||
placeholder={isLoadingTenants ? 'Loading tenants…' : 'Select tenant…'}
|
onChange={set('tenantid')}
|
||||||
isLoading={isLoadingTenants}
|
placeholder={isLoadingTenants ? 'Loading tenants…' : 'Select tenant…'}
|
||||||
hasSearch
|
isDisabled={isLoadingTenants}
|
||||||
/>
|
hasSearch
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<TextInput
|
<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}
|
value={form.locationname}
|
||||||
onChange={set('locationname')}
|
onChange={set('locationname')}
|
||||||
placeholder="e.g. RS Puram Super Hub"
|
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
|
<Selector
|
||||||
label="Who runs this outlet"
|
label="Who runs this outlet"
|
||||||
options={operatorOptions}
|
options={operatorOptions}
|
||||||
@@ -279,12 +348,7 @@ export function OnboardBranchPage() {
|
|||||||
? 'Choose someone you have hired'
|
? 'Choose someone you have hired'
|
||||||
: 'Nobody unplaced — a login will be created'
|
: 'Nobody unplaced — a login will be created'
|
||||||
}
|
}
|
||||||
description={
|
isDisabled={!effectiveTenantId}
|
||||||
unplaced.length > 0
|
|
||||||
? 'People you have added who are not at a shop yet.'
|
|
||||||
: 'Add people under Users & access to choose one here. Otherwise the outlet email below becomes the login.'
|
|
||||||
}
|
|
||||||
isDisabled={!form.tenantid}
|
|
||||||
/>
|
/>
|
||||||
<TextInput
|
<TextInput
|
||||||
label="Outlet email"
|
label="Outlet email"
|
||||||
@@ -292,11 +356,6 @@ export function OnboardBranchPage() {
|
|||||||
value={form.email}
|
value={form.email}
|
||||||
onChange={set('email')}
|
onChange={set('email')}
|
||||||
placeholder="rspuram@kaveri.com"
|
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
|
<TextInput
|
||||||
label="Outlet contact"
|
label="Outlet contact"
|
||||||
@@ -308,33 +367,90 @@ export function OnboardBranchPage() {
|
|||||||
</VStack>
|
</VStack>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Section 2: Location */}
|
||||||
<Card padding={0} elevation="low">
|
<Card padding={0} elevation="low">
|
||||||
<VStack gap={2} padding={3}>
|
<VStack gap={2} padding={3}>
|
||||||
<SectionHeader
|
<div
|
||||||
title="Location"
|
style={{
|
||||||
action={<MapPin size={17} style={{ color: 'var(--color-slate-400)' }} />}
|
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
|
<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}
|
value={form.address}
|
||||||
onChange={set('address')}
|
onChange={set('address')}
|
||||||
placeholder="e.g. 240, DB Road"
|
placeholder="e.g. 240, DB Road"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="form-grid-4">
|
<div className="form-grid-4">
|
||||||
<TextInput label="Suburb" value={form.suburb} onChange={set('suburb')} placeholder="e.g. RS Puram" />
|
<TextInput
|
||||||
<TextInput label={<span>City <span style={{ color: 'var(--color-error)' }}>*</span></span> as any} value={form.city} onChange={set('city')} />
|
label="Suburb"
|
||||||
<TextInput label={<span>State <span style={{ color: 'var(--color-error)' }}>*</span></span> as any} value={form.state} onChange={set('state')} />
|
value={form.suburb}
|
||||||
<TextInput label={<span>Postcode <span style={{ color: 'var(--color-error)' }}>*</span></span> as any} value={form.postcode} onChange={set('postcode')} />
|
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>
|
</div>
|
||||||
</VStack>
|
</VStack>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Section 3: Operating Hours & Delivery */}
|
||||||
<Card padding={0} elevation="low">
|
<Card padding={0} elevation="low">
|
||||||
<VStack gap={2} padding={3}>
|
<VStack gap={2} padding={3}>
|
||||||
<SectionHeader
|
<div
|
||||||
title="Hours and delivery"
|
style={{
|
||||||
action={<Clock size={17} style={{ color: 'var(--color-slate-400)' }} />}
|
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">
|
<div className="form-grid-4">
|
||||||
<TimeInput
|
<TimeInput
|
||||||
label="Open time"
|
label="Open time"
|
||||||
@@ -368,36 +484,71 @@ export function OnboardBranchPage() {
|
|||||||
</VStack>
|
</VStack>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Error Alert */}
|
||||||
{error ? (
|
{error ? (
|
||||||
<HStack
|
<HStack
|
||||||
align="center"
|
align="center"
|
||||||
gap={1}
|
gap={1}
|
||||||
padding={2}
|
padding={1.5}
|
||||||
style={{
|
style={{
|
||||||
background: 'var(--color-error-muted, #fceeee)',
|
background: 'var(--color-error-muted, #fceeee)',
|
||||||
borderRadius: 12,
|
borderRadius: 8,
|
||||||
color: 'var(--color-error, #d64545)',
|
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' }}>
|
<Text type="body" size="sm" style={{ color: 'inherit' }}>
|
||||||
{error}
|
{error}
|
||||||
</Text>
|
</Text>
|
||||||
</HStack>
|
</HStack>
|
||||||
) : null}
|
) : 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
|
<Button
|
||||||
label={mutation.isPending ? 'Commissioning…' : 'Commission branch'}
|
|
||||||
type="submit"
|
type="submit"
|
||||||
variant="primary"
|
variant="primary"
|
||||||
size="lg"
|
label={mutation.isPending ? 'Commissioning…' : 'Save & Continue'}
|
||||||
isLoading={mutation.isPending}
|
isLoading={mutation.isPending}
|
||||||
isDisabled={!isComplete}
|
isDisabled={!isComplete}
|
||||||
/>
|
/>
|
||||||
</HStack>
|
</div>
|
||||||
</VStack>
|
</VStack>
|
||||||
</form>
|
</form>
|
||||||
</PageBody>
|
</PageBody>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Safe hook for branch scope */
|
||||||
|
function useBranchScopeOptional() {
|
||||||
|
try {
|
||||||
|
return useBranchScope();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,10 +4,11 @@ import { HStack } from '@astryxdesign/core/HStack';
|
|||||||
import { Text } from '@astryxdesign/core/Text';
|
import { Text } from '@astryxdesign/core/Text';
|
||||||
import { TextInput } from '@astryxdesign/core/TextInput';
|
import { TextInput } from '@astryxdesign/core/TextInput';
|
||||||
import { VStack } from '@astryxdesign/core/VStack';
|
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 { customerLocality, customerName, type CustomerInfo } from '@/api/customers';
|
||||||
|
import { KpiCard } from '@/components/KpiCard';
|
||||||
import { PageHeader } from '@/components/PageHeader';
|
import { PageHeader } from '@/components/PageHeader';
|
||||||
import { useCustomers } from '@/queries/hooks';
|
import { useCustomers, useOrders } from '@/queries/hooks';
|
||||||
import { useBranchScope } from '@/features/store-admin/BranchScope';
|
import { useBranchScope } from '@/features/store-admin/BranchScope';
|
||||||
import { Drawer } from '@/features/store-admin/Drawer';
|
import { Drawer } from '@/features/store-admin/Drawer';
|
||||||
import { count } from '@/features/store-admin/format';
|
import { count } from '@/features/store-admin/format';
|
||||||
@@ -16,39 +17,109 @@ import { TablePager } from '@/components/TablePager';
|
|||||||
import { usePaged } from '@/components/usePaged';
|
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
|
* ── Built around what this backend actually fills in ────────────────────────
|
||||||
* 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.
|
|
||||||
*
|
*
|
||||||
* The list is genuinely branch-scoped by the server — `gettenantcustomers`
|
* `gettenantcustomers` returns 35 columns and most of them are empty. Measured
|
||||||
* joins `tenantcustomers` on `locationid` — which makes it one of the few reads
|
* across 31 real customers at 7 shops on 2026-09-15:
|
||||||
* here that would still be correct if someone bypassed this client.
|
*
|
||||||
|
* 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() {
|
export function StoreCustomersPage() {
|
||||||
const { current, tenantid } = useBranchScope();
|
const { current, tenantid } = useBranchScope();
|
||||||
const [keyword, setKeyword] = useState('');
|
const [keyword, setKeyword] = useState('');
|
||||||
const [open, setOpen] = useState<CustomerInfo | null>(null);
|
const [open, setOpen] = useState<CustomerInfo | null>(null);
|
||||||
|
|
||||||
|
const locationid = current?.locationid;
|
||||||
|
|
||||||
const customers = useCustomers(
|
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 rows = useMemo(() => {
|
||||||
const term = keyword.trim().toLowerCase();
|
|
||||||
const list = customers.data ?? [];
|
const list = customers.data ?? [];
|
||||||
if (term === '') return list;
|
const needle = keyword.trim().toLowerCase();
|
||||||
|
if (!needle) return list;
|
||||||
return list.filter((customer) =>
|
return list.filter((customer) =>
|
||||||
`${customerName(customer)} ${customer.contactno ?? ''} ${customer.email ?? ''} ${customer.address ?? ''}`
|
`${customerName(customer)} ${customer.contactno ?? ''} ${customer.email ?? ''} ${customer.address ?? ''}`
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.includes(term),
|
.includes(needle),
|
||||||
);
|
);
|
||||||
}, [customers.data, keyword]);
|
}, [customers.data, keyword]);
|
||||||
|
|
||||||
const paged = usePaged(rows, { resetKey: keyword });
|
const paged = usePaged(rows, { resetKey: keyword });
|
||||||
|
|
||||||
const withPhone = rows.filter((customer) => Boolean(customer.contactno)).length;
|
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 (
|
return (
|
||||||
<VStack gap={3}>
|
<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 ? (
|
{customers.isLoading ? (
|
||||||
<Note title="Reading your customer list…" />
|
<Note title="Reading your customer list…" />
|
||||||
) : rows.length === 0 ? (
|
) : rows.length === 0 ? (
|
||||||
@@ -83,8 +181,9 @@ export function StoreCustomersPage() {
|
|||||||
) : (
|
) : (
|
||||||
<VStack gap={1.5}>
|
<VStack gap={1.5}>
|
||||||
<Text type="body" size="sm" color="secondary">
|
<Text type="body" size="sm" color="secondary">
|
||||||
{count(rows.length)} customer{rows.length === 1 ? '' : 's'} · {count(withPhone)} with a
|
{count(rows.length)} customer{rows.length === 1 ? '' : 's'}
|
||||||
phone number
|
{topLocality ? ` · ${count(topLocality.n)} in ${topLocality.area}` : ''} · orders counted
|
||||||
|
over their whole history
|
||||||
</Text>
|
</Text>
|
||||||
<Card padding={0} elevation="low">
|
<Card padding={0} elevation="low">
|
||||||
<div className="table-scroll">
|
<div className="table-scroll">
|
||||||
@@ -97,37 +196,57 @@ export function StoreCustomersPage() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<colgroup>
|
<colgroup>
|
||||||
<col style={{ width: '26%' }} />
|
<col style={{ width: '38%' }} />
|
||||||
<col style={{ width: '18%' }} />
|
<col style={{ width: '20%' }} />
|
||||||
<col style={{ width: '26%' }} />
|
<col style={{ width: '28%' }} />
|
||||||
<col style={{ width: '30%' }} />
|
<col style={{ width: '14%' }} />
|
||||||
</colgroup>
|
</colgroup>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<Th>Customer</Th>
|
<Th>Customer</Th>
|
||||||
<Th>Phone</Th>
|
<Th>Phone</Th>
|
||||||
<Th>Email</Th>
|
|
||||||
<Th>Where</Th>
|
<Th>Where</Th>
|
||||||
|
<Th align="end">Orders</Th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{paged.rows.map((customer) => (
|
{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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<TablePager paged={paged} label="customers" />
|
<TablePager paged={paged} label="customers" />
|
||||||
</Card>
|
</Card>
|
||||||
</VStack>
|
</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>
|
</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);
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
return (
|
return (
|
||||||
<tr
|
<tr
|
||||||
@@ -152,26 +271,33 @@ function Row({ customer, onOpen }: { customer: CustomerInfo; onOpen: () => void
|
|||||||
<Td>
|
<Td>
|
||||||
<HStack gap={1.5} align="center">
|
<HStack gap={1.5} align="center">
|
||||||
<Initials name={customerName(customer)} />
|
<Initials name={customerName(customer)} />
|
||||||
<Text type="label" size="sm" weight="semibold" maxLines={1}>
|
{/* The email rides under the name rather than holding a column of its
|
||||||
{customerName(customer)}
|
own: 42% of customers have one, so as a column it was mostly
|
||||||
</Text>
|
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>
|
</HStack>
|
||||||
</Td>
|
</Td>
|
||||||
<Td isMuted>
|
<Td isMuted>
|
||||||
<span style={{ fontVariantNumeric: 'tabular-nums' }}>{customer.contactno || '—'}</span>
|
<span style={{ fontVariantNumeric: 'tabular-nums' }}>{customer.contactno || '—'}</span>
|
||||||
</Td>
|
</Td>
|
||||||
<Td isMuted>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
display: 'block',
|
|
||||||
overflow: 'hidden',
|
|
||||||
textOverflow: 'ellipsis',
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{customer.email || '—'}
|
|
||||||
</span>
|
|
||||||
</Td>
|
|
||||||
<Td isMuted>
|
<Td isMuted>
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
@@ -184,17 +310,41 @@ function Row({ customer, onOpen }: { customer: CustomerInfo; onOpen: () => void
|
|||||||
{customerLocality(customer)}
|
{customerLocality(customer)}
|
||||||
</span>
|
</span>
|
||||||
</Td>
|
</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>
|
</tr>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CustomerDrawer({
|
function CustomerDrawer({
|
||||||
customer,
|
customer,
|
||||||
|
orders,
|
||||||
|
rangeLabel,
|
||||||
onClose,
|
onClose,
|
||||||
}: {
|
}: {
|
||||||
customer: CustomerInfo;
|
customer: CustomerInfo;
|
||||||
|
orders: number;
|
||||||
|
rangeLabel: string;
|
||||||
onClose: () => void;
|
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 = [
|
const address = [
|
||||||
customer.doorno,
|
customer.doorno,
|
||||||
customer.address,
|
customer.address,
|
||||||
@@ -214,17 +364,39 @@ function CustomerDrawer({
|
|||||||
width={420}
|
width={420}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
>
|
>
|
||||||
<Card padding={0} elevation="low">
|
<VStack gap={1.5}>
|
||||||
<VStack gap={1} padding={2}>
|
<Card padding={0} elevation="low">
|
||||||
<Line icon={<Phone size={13} />} label="Phone" value={customer.contactno || '—'} />
|
<VStack gap={1} padding={2}>
|
||||||
<Line icon={<Mail size={13} />} label="Email" value={customer.email || '—'} />
|
<Line icon={<Phone size={13} />} label="Phone" value={customer.contactno || '—'} />
|
||||||
<Line icon={<MapPin size={13} />} label="Address" value={address || '—'} />
|
<Line icon={<Mail size={13} />} label="Email" value={customer.email || '—'} />
|
||||||
</VStack>
|
<Line icon={<MapPin size={13} />} label="Address" value={address || '—'} />
|
||||||
</Card>
|
</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>
|
</Drawer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Line({
|
function Line({
|
||||||
icon,
|
icon,
|
||||||
label,
|
label,
|
||||||
|
|||||||
@@ -1,10 +1,21 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { Button } from '@astryxdesign/core/Button';
|
import { Button } from '@astryxdesign/core/Button';
|
||||||
import { Card } from '@astryxdesign/core/Card';
|
import { Card } from '@astryxdesign/core/Card';
|
||||||
|
import { HStack } from '@astryxdesign/core/HStack';
|
||||||
import { Text } from '@astryxdesign/core/Text';
|
import { Text } from '@astryxdesign/core/Text';
|
||||||
import { VStack } from '@astryxdesign/core/VStack';
|
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 type { PosUser } from '@/api/types';
|
||||||
|
import { KpiCard } from '@/components/KpiCard';
|
||||||
import { PageHeader } from '@/components/PageHeader';
|
import { PageHeader } from '@/components/PageHeader';
|
||||||
import { usePosRoles, usePosUsersByBranch, useStaffShifts } from '@/queries/hooks';
|
import { usePosRoles, usePosUsersByBranch, useStaffShifts } from '@/queries/hooks';
|
||||||
import { useBranchScope } from '@/features/store-admin/BranchScope';
|
import { useBranchScope } from '@/features/store-admin/BranchScope';
|
||||||
@@ -15,45 +26,91 @@ import { TablePager } from '@/components/TablePager';
|
|||||||
import { usePaged } from '@/components/usePaged';
|
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
|
* ── Built around what `getposusers` actually returns ────────────────────────
|
||||||
* 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.
|
|
||||||
*
|
*
|
||||||
* Two things are worth knowing before reading the table. A till account has no
|
* Measured across all 14 live till accounts at 6 branches on 2026-09-15:
|
||||||
* 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
|
* user_id, full_name, first_name, authname, role_id, role,
|
||||||
* there is no delete: deactivating stops the login and keeps their bills, which
|
* pin, has_password, location_id, status 100%
|
||||||
* a delete would orphan.
|
* 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() {
|
export function StoreStaffPage() {
|
||||||
const { current, tenantid } = useBranchScope();
|
const { current, tenantid } = useBranchScope();
|
||||||
const locationid = current?.locationid;
|
|
||||||
|
|
||||||
const [editing, setEditing] = useState<PosUser | null>(null);
|
const [editing, setEditing] = useState<PosUser | null>(null);
|
||||||
const [isAdding, setAdding] = useState(false);
|
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 roles = usePosRoles();
|
||||||
const shifts = useStaffShifts(tenantid || undefined, locationid);
|
const shifts = useStaffShifts(tenantid || undefined, locationid);
|
||||||
|
|
||||||
const page = pages[0];
|
const page = pages[0];
|
||||||
|
|
||||||
|
const everyone = useMemo(() => page?.data ?? [], [page?.data]);
|
||||||
|
|
||||||
const rows = useMemo(() => {
|
const rows = useMemo(() => {
|
||||||
const list = page?.data ?? [];
|
|
||||||
// Cashiers only. A supervisor may exist at this outlet — the merchant
|
// 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
|
// 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
|
// 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.
|
// rows is a control that only ever gets in the way.
|
||||||
return list
|
return everyone
|
||||||
.filter((person) => (person.role ?? '').toLowerCase() !== 'supervisor')
|
.filter((person) => (person.role ?? '').toLowerCase() !== 'supervisor')
|
||||||
.sort((a, b) => (a.full_name ?? '').localeCompare(b.full_name ?? ''));
|
.sort((a, b) => (a.full_name ?? '').localeCompare(b.full_name ?? ''));
|
||||||
}, [page?.data]);
|
}, [everyone]);
|
||||||
|
|
||||||
const paged = usePaged(rows);
|
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 (
|
return (
|
||||||
<VStack gap={3}>
|
<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 ? (
|
{page?.isLoading ? (
|
||||||
<Note title="Reading your counter staff…" />
|
<Note title="Reading your counter staff…" />
|
||||||
) : rows.length === 0 ? (
|
) : rows.length === 0 ? (
|
||||||
<Note
|
<Note
|
||||||
title="No cashiers yet"
|
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}>
|
<VStack gap={1.5}>
|
||||||
<Text type="body" size="sm" color="secondary">
|
<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>
|
</Text>
|
||||||
<Card padding={0} elevation="low">
|
<Card padding={0} elevation="low">
|
||||||
<div className="table-scroll">
|
<div className="table-scroll">
|
||||||
@@ -94,51 +210,66 @@ export function StoreStaffPage() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<colgroup>
|
<colgroup>
|
||||||
<col style={{ width: '24%' }} />
|
<col style={{ width: '26%' }} />
|
||||||
<col style={{ width: '14%' }} />
|
<col style={{ width: '14%' }} />
|
||||||
|
<col style={{ width: '30%' }} />
|
||||||
<col style={{ width: '18%' }} />
|
<col style={{ width: '18%' }} />
|
||||||
<col style={{ width: '20%' }} />
|
<col style={{ width: '12%' }} />
|
||||||
<col style={{ width: '14%' }} />
|
|
||||||
<col style={{ width: '10%' }} />
|
|
||||||
</colgroup>
|
</colgroup>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<Th>Name</Th>
|
<Th>Staff</Th>
|
||||||
<Th>Role</Th>
|
<Th>Role</Th>
|
||||||
<Th>Mobile</Th>
|
<Th>Till sign-in</Th>
|
||||||
<Th>Shift</Th>
|
<Th>Access</Th>
|
||||||
<Th>Status</Th>
|
|
||||||
<Th />
|
<Th />
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{paged.rows.map((person) => (
|
{paged.rows.map((person) => (
|
||||||
<tr key={person.user_id}>
|
<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>
|
<Td>
|
||||||
<RoleChip label={person.role} />
|
<RoleChip label={person.role} />
|
||||||
</Td>
|
</Td>
|
||||||
<Td isMuted>
|
<Td>
|
||||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 12 }}>
|
<TillSignIn person={person} />
|
||||||
{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>
|
</Td>
|
||||||
<Td>
|
<Td>
|
||||||
<StatusChip status={person.status} />
|
<AccessChip person={person} />
|
||||||
</Td>
|
</Td>
|
||||||
<Td>
|
<Td>
|
||||||
<Button
|
<Button
|
||||||
@@ -153,7 +284,7 @@ export function StoreStaffPage() {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<TablePager paged={paged} label="people" />
|
<TablePager paged={paged} label="people" />
|
||||||
</Card>
|
</Card>
|
||||||
</VStack>
|
</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 }) {
|
function Note({ title, body }: { title: string; body?: string }) {
|
||||||
return (
|
return (
|
||||||
<Card padding={0} elevation="low">
|
<Card padding={0} elevation="low">
|
||||||
@@ -218,32 +465,3 @@ function RoleChip({ label }: { label: string | undefined }) {
|
|||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A dot and a word, not a pill — the merchant's page reads the same way. */
|
|
||||||
function StatusChip({ status }: { status: string | undefined }) {
|
|
||||||
// A missing status is UNKNOWN, not active. Defaulting it to active painted a
|
|
||||||
// green dot and the word "Active" beside an account whose state the API never
|
|
||||||
// reported — which is the one case where somebody most needs to look.
|
|
||||||
const isActive = (status ?? '').toLowerCase().startsWith('active');
|
|
||||||
const isUnknown = !status;
|
|
||||||
const colour = isUnknown
|
|
||||||
? 'var(--color-ink-4)'
|
|
||||||
: isActive
|
|
||||||
? 'var(--color-success, #10b981)'
|
|
||||||
: 'var(--color-ink-4)';
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
display: 'inline-flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 5,
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: 500,
|
|
||||||
color: isActive ? 'var(--color-ink-2)' : 'var(--color-ink-4)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span style={{ width: 6, height: 6, borderRadius: 999, background: colour, flex: 'none' }} />
|
|
||||||
{isUnknown ? 'Unknown' : isActive ? 'Active' : status}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -112,11 +112,25 @@ export function BarAction({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Th({ children }: { children?: ReactNode }) {
|
/**
|
||||||
|
* A column heading.
|
||||||
|
*
|
||||||
|
* `align` defaults to centre because every store-user table was built that
|
||||||
|
* way and changing them all is not this prop's job. Pass `"end"` for a column
|
||||||
|
* of numbers: a figure is read against the ones above and below it, and that
|
||||||
|
* only works when the digits line up on the right.
|
||||||
|
*/
|
||||||
|
export function Th({
|
||||||
|
children,
|
||||||
|
align = 'center',
|
||||||
|
}: {
|
||||||
|
children?: ReactNode;
|
||||||
|
align?: 'start' | 'center' | 'end';
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<th
|
<th
|
||||||
style={{
|
style={{
|
||||||
textAlign: 'center',
|
textAlign: align,
|
||||||
padding: '10px',
|
padding: '10px',
|
||||||
borderBottom: '1px solid var(--color-line)',
|
borderBottom: '1px solid var(--color-line)',
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
@@ -134,15 +148,17 @@ export function Td({
|
|||||||
children,
|
children,
|
||||||
isMuted,
|
isMuted,
|
||||||
isStrong,
|
isStrong,
|
||||||
|
align = 'center',
|
||||||
}: {
|
}: {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
isMuted?: boolean;
|
isMuted?: boolean;
|
||||||
isStrong?: boolean;
|
isStrong?: boolean;
|
||||||
|
align?: 'start' | 'center' | 'end';
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<td
|
<td
|
||||||
style={{
|
style={{
|
||||||
textAlign: 'center',
|
textAlign: align,
|
||||||
padding: '10px',
|
padding: '10px',
|
||||||
borderBottom: '1px solid color-mix(in oklab, var(--color-line) 55%, transparent)',
|
borderBottom: '1px solid color-mix(in oklab, var(--color-line) 55%, transparent)',
|
||||||
color: isMuted ? 'var(--color-ink-3)' : 'var(--color-ink-1)',
|
color: isMuted ? 'var(--color-ink-3)' : 'var(--color-ink-1)',
|
||||||
|
|||||||
@@ -546,11 +546,23 @@ export function useStaff(tenantid: number | undefined) {
|
|||||||
* Fanned out because `getposusers` takes a single `locationid` — the same
|
* Fanned out because `getposusers` takes a single `locationid` — the same
|
||||||
* constraint as every other POS read.
|
* 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({
|
return useQueries({
|
||||||
queries: locationIds.map((locationid) => ({
|
queries: locationIds.map((locationid) => ({
|
||||||
queryKey: queryKeys.people.posUsers(tenantid ?? 0, locationid),
|
queryKey: [...queryKeys.people.posUsers(tenantid ?? 0, locationid), includeInactive] as const,
|
||||||
queryFn: () => posUsersApi.list(tenantid as number, locationid),
|
queryFn: () => posUsersApi.list(tenantid as number, locationid, includeInactive),
|
||||||
enabled: Boolean(tenantid),
|
enabled: Boolean(tenantid),
|
||||||
...stable,
|
...stable,
|
||||||
})),
|
})),
|
||||||
|
|||||||
Reference in New Issue
Block a user