design
This commit is contained in:
@@ -1,82 +1,21 @@
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { SegmentedControl, SegmentedControlItem } from '@astryxdesign/core/SegmentedControl';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { PackageSearch, Upload } from 'lucide-react';
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
import { CatalogueBrowser } from '@/features/catalogue/CatalogueBrowser';
|
||||
import { SheetImportPanel } from '../import/SheetImportPanel';
|
||||
|
||||
/**
|
||||
* The platform operator's catalogue.
|
||||
*
|
||||
* TWO MODES, AND THEY ASK FOR DIFFERENT THINGS.
|
||||
*
|
||||
* - **Browse** is reading. The operator looks at what the FMCG catalogue holds
|
||||
* — the photographs, the pack sizes, the FSSAI licences, what is stocked
|
||||
* where — and nothing is written. There is no merchant to choose because
|
||||
* nothing lands anywhere, so the page is the rail, the search and the grid
|
||||
* and nothing else. The per-product Add is gone from here: stocking one
|
||||
* shop at a time is the merchant's own job, in Store Admin ▸ Inventory ▸
|
||||
* Catalogue.
|
||||
*
|
||||
* - **Upload sheet** is writing, and it writes the GLOBAL catalogue — not one
|
||||
* merchant's shelf. The ingest service parses, enriches and stores rows in
|
||||
* the per-brand tables every merchant reads from, and it takes no tenant and
|
||||
* no outlet. This mode therefore asks for nothing but a file.
|
||||
*
|
||||
* The grid, the filters, the cards and the detail drawer are the shared
|
||||
* `CatalogueBrowser` — the same ones the Store Admin sees, because it is the
|
||||
* same catalogue.
|
||||
*/
|
||||
export function GlobalCataloguePage() {
|
||||
/*
|
||||
The tab is in the URL so it can be linked to.
|
||||
|
||||
Uploads has an "Upload spreadsheet" button and this is where that action
|
||||
lives for a platform operator — it writes the GLOBAL catalogue, which is not
|
||||
the same action as a merchant uploading their own list. Held in local state it
|
||||
could only be reached by landing on Browse and pressing a second control,
|
||||
which is a detour rather than a flow.
|
||||
*/
|
||||
const [params, setParams] = useSearchParams();
|
||||
const [params] = useSearchParams();
|
||||
const mode = params.get('tab') === 'sheet' ? 'sheet' : 'catalogue';
|
||||
const setMode = (next: 'catalogue' | 'sheet') => {
|
||||
const merged = new URLSearchParams(params);
|
||||
if (next === 'catalogue') merged.delete('tab');
|
||||
else merged.set('tab', next);
|
||||
setParams(merged, { replace: true });
|
||||
};
|
||||
|
||||
return (
|
||||
<VStack gap={3}>
|
||||
<PageHeader
|
||||
title="Global catalogue"
|
||||
actions={
|
||||
<SegmentedControl
|
||||
label="What to do"
|
||||
value={mode}
|
||||
onChange={(value) => setMode(value as 'catalogue' | 'sheet')}
|
||||
size="sm"
|
||||
>
|
||||
<SegmentedControlItem value="catalogue" label="Browse catalogue" icon={<PackageSearch size={14} />} />
|
||||
<SegmentedControlItem value="sheet" label="Upload sheet" icon={<Upload size={14} />} />
|
||||
</SegmentedControl>
|
||||
}
|
||||
/>
|
||||
<PageHeader title="Global catalogue" />
|
||||
|
||||
{mode === 'sheet' ? (
|
||||
/* The merchant and outlet pickers used to sit above this panel, and
|
||||
they described a flow that no longer exists.
|
||||
|
||||
The ingest endpoint writes the GLOBAL catalogue. It has no concept of
|
||||
a tenant or an outlet — putting a product on one shop's shelf with a
|
||||
price and opening stock is a separate call (`/api/upload/stores`,
|
||||
joined on `image_id`) that is not wired up yet. Two selectors saying
|
||||
the upload was "written against one merchant and one outlet" would
|
||||
have had someone pick a shop, upload, and then go looking for stock
|
||||
that was never going to arrive.
|
||||
|
||||
They come back with the inventory step, and mean something then. */
|
||||
<SheetImportPanel />
|
||||
) : (
|
||||
<CatalogueBrowser
|
||||
|
||||
@@ -8,12 +8,16 @@ import { Selector } from '@astryxdesign/core/Selector';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { TextInput } from '@astryxdesign/core/TextInput';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { AlertCircle, Building2, CheckCircle2, MapPin } from 'lucide-react';
|
||||
import {
|
||||
AlertCircle,
|
||||
ArrowLeft,
|
||||
Building2,
|
||||
CheckCircle2,
|
||||
MapPin,
|
||||
} from 'lucide-react';
|
||||
import { tenantsApi, type CreateTenantRequest } from '@/api/tenants';
|
||||
import { errorMessage } from '@/api/client';
|
||||
import { PageBody } from '@/components/PageBody';
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
import { SectionHeader } from '@/components/SectionHeader';
|
||||
import { queryKeys } from '@/queries/keys';
|
||||
import { StoreQrPanel } from '@/features/qr/StoreQrPanel';
|
||||
import { useAppCategories } from '@/queries/hooks';
|
||||
@@ -21,7 +25,6 @@ import { useAppCategories } from '@/queries/hooks';
|
||||
interface FormState {
|
||||
tenantname: string;
|
||||
companyname: string;
|
||||
/** Who runs the shop. Written to `tenants.firstname` — see `CreateTenantRequest`. */
|
||||
adminname: string;
|
||||
primarycontact: string;
|
||||
primaryemail: string;
|
||||
@@ -41,45 +44,23 @@ const EMPTY: FormState = {
|
||||
primarycontact: '',
|
||||
primaryemail: '',
|
||||
locationname: '',
|
||||
// No default category. `utils/getappcategories` supplies the real list and
|
||||
// this used to pre-select the first id regardless of what the merchant sells.
|
||||
categoryid: '',
|
||||
address: '',
|
||||
suburb: '',
|
||||
// Empty, not pre-filled.
|
||||
//
|
||||
// These carried 'Coimbatore' and 'Tamil Nadu' as VALUES, not placeholders —
|
||||
// so a merchant anywhere else was submitted with the wrong city and state
|
||||
// unless somebody noticed and cleared them. A default that is right most of
|
||||
// the time is worse than a blank field, because it is only checked when it
|
||||
// looks wrong.
|
||||
city: '',
|
||||
state: '',
|
||||
postcode: '',
|
||||
};
|
||||
|
||||
/**
|
||||
* Provision a new merchant tenant.
|
||||
* Onboard Tenant Page
|
||||
*
|
||||
* This registers the enterprise, its first outlet, and spawns the primary
|
||||
* Administrator account — account creation is a side effect of provisioning
|
||||
* here, because that is what the backend does. There is no separate invite.
|
||||
*
|
||||
* Products are NOT collected on this form. Once the tenant exists it has a
|
||||
* tenantid and a locationid, and only then can either import path run: both
|
||||
* `importcatalogueproduct` and `createproductlocation` require the pair. So the
|
||||
* form hands off to the catalogue on success rather than pretending the two
|
||||
* steps are one.
|
||||
* Clean, standard enterprise SaaS onboarding experience using native design system inputs and components.
|
||||
*/
|
||||
export function OnboardTenantPage() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [form, setForm] = useState<FormState>(EMPTY);
|
||||
/**
|
||||
* The category list comes from `app_category`, not from four values typed
|
||||
* into this file. A category added to the master should appear here without
|
||||
* a release.
|
||||
*/
|
||||
const categories = useAppCategories();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -110,6 +91,8 @@ export function OnboardTenantPage() {
|
||||
|
||||
function handleSubmit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
if (!isComplete || mutation.isPending) return;
|
||||
|
||||
setError(null);
|
||||
mutation.mutate({
|
||||
tenantname: form.tenantname.trim(),
|
||||
@@ -118,8 +101,6 @@ export function OnboardTenantPage() {
|
||||
primarycontact: form.primarycontact.trim(),
|
||||
primaryemail: form.primaryemail.trim(),
|
||||
locationname: form.locationname.trim(),
|
||||
// `Number('')` is NaN, which serialises to null and is not what the
|
||||
// backend means by "uncategorised" — 0 is.
|
||||
categoryid: Number(form.categoryid) || 0,
|
||||
address: form.address.trim(),
|
||||
suburb: form.suburb.trim(),
|
||||
@@ -133,121 +114,190 @@ export function OnboardTenantPage() {
|
||||
if (mutation.isSuccess) {
|
||||
const created = mutation.data;
|
||||
return (
|
||||
<VStack gap={3}>
|
||||
<PageHeader
|
||||
title="Tenant provisioned"
|
||||
/>
|
||||
<Card padding={4} elevation="low">
|
||||
<VStack gap={3}>
|
||||
<HStack align="center" gap={1.5}>
|
||||
<CheckCircle2 size={22} style={{ color: 'var(--color-success, #10b981)' }} />
|
||||
<Text type="large" weight="semibold">
|
||||
{form.tenantname} is live
|
||||
<PageBody measure="reading">
|
||||
<VStack gap={3}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/nearle/stores')}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
padding: '4px 0',
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
color: 'var(--color-ink-3, #657081)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<ArrowLeft size={14} />
|
||||
<span>Back to stores</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Card padding={4} elevation="low">
|
||||
<VStack gap={3}>
|
||||
<HStack align="center" gap={1.5}>
|
||||
<CheckCircle2 size={24} style={{ color: 'var(--color-success, #10b981)' }} />
|
||||
<Text type="large" weight="semibold">
|
||||
{form.tenantname} is live
|
||||
</Text>
|
||||
</HStack>
|
||||
<Text type="body" color="secondary">
|
||||
Its first outlet, {form.locationname}, has been commissioned. The next step is
|
||||
stocking the catalogue — pick products from the global catalogue, or upload the
|
||||
tenant's own list as a spreadsheet.
|
||||
</Text>
|
||||
</HStack>
|
||||
<Text type="body" color="secondary">
|
||||
Its first outlet, {form.locationname}, has been commissioned. The next step is
|
||||
stocking the catalogue — pick products from the global catalogue, or upload the
|
||||
tenant's own list as a spreadsheet.
|
||||
</Text>
|
||||
|
||||
{/* The storefront code, at the one moment the person who provisioned
|
||||
the shop is holding its details.
|
||||
|
||||
It was reachable only from the branch user's own header, which
|
||||
is the wrong place for it: the code is what puts the store in
|
||||
front of a shopper at all — nobody can order from a shop they
|
||||
have not scanned — and the person onboarding it is the one who
|
||||
sends it to the merchant. `createtenantuser` returns the tenant
|
||||
and its primary outlet's id together, so it can be drawn here
|
||||
without a second read. */}
|
||||
{created?.tenantid && created?.locationid ? (
|
||||
<Card padding={3} elevation="low" variant="transparent">
|
||||
<StoreQrPanel
|
||||
tenantid={created.tenantid}
|
||||
locationid={created.locationid}
|
||||
locationname={form.locationname}
|
||||
where={[form.suburb, form.city].filter(Boolean).join(', ')}
|
||||
{created?.tenantid && created?.locationid ? (
|
||||
<Card padding={3} elevation="low" variant="transparent">
|
||||
<StoreQrPanel
|
||||
tenantid={created.tenantid}
|
||||
locationid={created.locationid}
|
||||
locationname={form.locationname}
|
||||
where={[form.suburb, form.city].filter(Boolean).join(', ')}
|
||||
/>
|
||||
</Card>
|
||||
) : null}
|
||||
<HStack gap={1.5} wrap="wrap">
|
||||
<Button
|
||||
label="Stock from global catalogue"
|
||||
variant="primary"
|
||||
onClick={() =>
|
||||
navigate(
|
||||
`/nearle/catalogue?tenantid=${created?.tenantid ?? ''}&locationid=${created?.locationid ?? ''}`,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
) : null}
|
||||
<HStack gap={1.5} wrap="wrap">
|
||||
<Button
|
||||
label="Stock from global catalogue"
|
||||
variant="primary"
|
||||
onClick={() =>
|
||||
navigate(
|
||||
`/nearle/catalogue?tenantid=${created?.tenantid ?? ''}&locationid=${created?.locationid ?? ''}`,
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
label="Back to stores"
|
||||
variant="secondary"
|
||||
onClick={() => navigate('/nearle/stores')}
|
||||
/>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Card>
|
||||
</VStack>
|
||||
<Button
|
||||
label="Back to stores"
|
||||
variant="secondary"
|
||||
onClick={() => navigate('/nearle/stores')}
|
||||
/>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Card>
|
||||
</VStack>
|
||||
</PageBody>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageBody measure="reading">
|
||||
<PageHeader
|
||||
title="Onboard tenant"
|
||||
/>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<form onSubmit={handleSubmit} style={{ width: '100%' }}>
|
||||
<VStack gap={3}>
|
||||
{/* Top Back Action & Required Indicator */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 16,
|
||||
}}
|
||||
>
|
||||
<h1 className="sr-only">Onboard Tenant</h1>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/nearle/stores')}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 5,
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
padding: '4px 0',
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
color: 'var(--color-ink-3, #657081)',
|
||||
cursor: 'pointer',
|
||||
transition: 'color 0.15s ease',
|
||||
}}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.color = 'var(--color-brand, #662582)')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.color = 'var(--color-ink-3, #657081)')}
|
||||
>
|
||||
<ArrowLeft size={14} />
|
||||
<span>Back to stores</span>
|
||||
</button>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
padding: '3px 8px',
|
||||
borderRadius: 6,
|
||||
background: 'var(--color-surface-sunken, #f2f4f7)',
|
||||
border: '1px solid var(--color-line, #e0e4ea)',
|
||||
fontSize: 11.5,
|
||||
color: 'var(--color-ink-3, #657081)',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
<span>Fields marked with <span style={{ color: 'var(--color-error, #d64545)', fontWeight: 600 }}>*</span> are required</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Section 1: Business Details Card */}
|
||||
<Card padding={0} elevation="low">
|
||||
<VStack gap={2} padding={3}>
|
||||
<SectionHeader
|
||||
title="Business"
|
||||
action={<Building2 size={17} style={{ color: 'var(--color-slate-400)' }} />}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
paddingBottom: 10,
|
||||
borderBottom: '1px solid var(--color-line, #e0e4ea)',
|
||||
}}
|
||||
>
|
||||
<Building2 size={16} style={{ color: 'var(--color-brand, #662582)' }} />
|
||||
<span
|
||||
style={{
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
color: 'var(--color-ink-1, #0f172a)',
|
||||
}}
|
||||
>
|
||||
Business Details
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="form-grid">
|
||||
<TextInput
|
||||
label={<span>Merchant name <span style={{ color: 'var(--color-error)' }}>*</span></span> as any}
|
||||
label={<span>Merchant name <span style={{ color: 'var(--color-error, #d64545)' }}>*</span></span> as any}
|
||||
value={form.tenantname}
|
||||
onChange={set('tenantname')}
|
||||
placeholder="e.g. Kaveri Groceries"
|
||||
/>
|
||||
<TextInput
|
||||
label={<span>Company registered name <span style={{ color: 'var(--color-error)' }}>*</span></span> as any}
|
||||
label={<span>Company registered name <span style={{ color: 'var(--color-error, #d64545)' }}>*</span></span> as any}
|
||||
value={form.companyname}
|
||||
onChange={set('companyname')}
|
||||
placeholder="e.g. Kaveri Retail Pvt. Ltd."
|
||||
/>
|
||||
{/* Asked here because here is the only place it can be asked.
|
||||
The primary outlet and the merchant's own admin login are
|
||||
both created inside this one call, and the login is copied
|
||||
from the tenant row — so the name given here names the
|
||||
business AND the account that signs in. Left out, both are
|
||||
blank, which is the state every merchant is in today. */}
|
||||
<TextInput
|
||||
label={<span>Store admin <span style={{ color: 'var(--color-error)' }}>*</span></span> as any}
|
||||
label={<span>Store admin <span style={{ color: 'var(--color-error, #d64545)' }}>*</span></span> as any}
|
||||
value={form.adminname}
|
||||
onChange={set('adminname')}
|
||||
placeholder="e.g. Ravi Kumar"
|
||||
description="The person who administers this shop. Their name goes on the store profile and on the admin login created with it — the account is provisioned as Admin, which is what this field is named after."
|
||||
/>
|
||||
<TextInput
|
||||
label={<span>Primary phone <span style={{ color: 'var(--color-error)' }}>*</span></span> as any}
|
||||
label={<span>Primary phone <span style={{ color: 'var(--color-error, #d64545)' }}>*</span></span> as any}
|
||||
value={form.primarycontact}
|
||||
onChange={set('primarycontact')}
|
||||
placeholder="9876543210"
|
||||
/>
|
||||
<TextInput
|
||||
label={<span>Primary admin email <span style={{ color: 'var(--color-error)' }}>*</span></span> as any}
|
||||
label={<span>Primary admin email <span style={{ color: 'var(--color-error, #d64545)' }}>*</span></span> as any}
|
||||
type="email"
|
||||
value={form.primaryemail}
|
||||
onChange={set('primaryemail')}
|
||||
placeholder="admin@kaveri.com"
|
||||
/>
|
||||
<TextInput
|
||||
label={<span>First outlet name <span style={{ color: 'var(--color-error)' }}>*</span></span> as any}
|
||||
label={<span>First outlet name <span style={{ color: 'var(--color-error, #d64545)' }}>*</span></span> as any}
|
||||
value={form.locationname}
|
||||
onChange={set('locationname')}
|
||||
placeholder="e.g. Kaveri RS Puram"
|
||||
@@ -258,6 +308,7 @@ export function OnboardTenantPage() {
|
||||
value: String(entry.categoryid),
|
||||
label: entry.categoryname,
|
||||
}))}
|
||||
placeholder="Select category"
|
||||
isDisabled={categories.isLoading}
|
||||
value={form.categoryid}
|
||||
onChange={set('categoryid')}
|
||||
@@ -266,24 +317,58 @@ export function OnboardTenantPage() {
|
||||
</VStack>
|
||||
</Card>
|
||||
|
||||
{/* Section 2: Head Office Card */}
|
||||
<Card padding={0} elevation="low">
|
||||
<VStack gap={2} padding={3}>
|
||||
<SectionHeader
|
||||
title="Head office"
|
||||
action={<MapPin size={17} style={{ color: 'var(--color-slate-400)' }} />}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
paddingBottom: 10,
|
||||
borderBottom: '1px solid var(--color-line, #e0e4ea)',
|
||||
}}
|
||||
>
|
||||
<MapPin size={16} style={{ color: 'var(--color-brand, #662582)' }} />
|
||||
<span
|
||||
style={{
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
color: 'var(--color-ink-1, #0f172a)',
|
||||
}}
|
||||
>
|
||||
Head Office
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<TextInput
|
||||
label={<span>Street address <span style={{ color: 'var(--color-error)' }}>*</span></span> as any}
|
||||
label={<span>Street address <span style={{ color: 'var(--color-error, #d64545)' }}>*</span></span> as any}
|
||||
value={form.address}
|
||||
onChange={set('address')}
|
||||
placeholder="e.g. 12, Avinashi Road"
|
||||
/>
|
||||
|
||||
<div className="form-grid-4">
|
||||
<TextInput label="Suburb" value={form.suburb} onChange={set('suburb')} placeholder="e.g. Peelamedu" />
|
||||
<TextInput label={<span>City <span style={{ color: 'var(--color-error)' }}>*</span></span> as any} value={form.city} onChange={set('city')} />
|
||||
<TextInput label={<span>State <span style={{ color: 'var(--color-error)' }}>*</span></span> as any} value={form.state} onChange={set('state')} />
|
||||
<TextInput
|
||||
label={<span>Postcode <span style={{ color: 'var(--color-error)' }}>*</span></span> as any}
|
||||
label="Suburb"
|
||||
value={form.suburb}
|
||||
onChange={set('suburb')}
|
||||
placeholder="e.g. Peelamedu"
|
||||
/>
|
||||
<TextInput
|
||||
label={<span>City <span style={{ color: 'var(--color-error, #d64545)' }}>*</span></span> as any}
|
||||
value={form.city}
|
||||
onChange={set('city')}
|
||||
placeholder="e.g. Coimbatore"
|
||||
/>
|
||||
<TextInput
|
||||
label={<span>State <span style={{ color: 'var(--color-error, #d64545)' }}>*</span></span> as any}
|
||||
value={form.state}
|
||||
onChange={set('state')}
|
||||
placeholder="e.g. Tamil Nadu"
|
||||
/>
|
||||
<TextInput
|
||||
label={<span>Postcode <span style={{ color: 'var(--color-error, #d64545)' }}>*</span></span> as any}
|
||||
value={form.postcode}
|
||||
onChange={set('postcode')}
|
||||
placeholder="641004"
|
||||
@@ -292,34 +377,60 @@ export function OnboardTenantPage() {
|
||||
</VStack>
|
||||
</Card>
|
||||
|
||||
{/* Error Alert */}
|
||||
{error ? (
|
||||
<HStack
|
||||
align="center"
|
||||
gap={1}
|
||||
padding={2}
|
||||
padding={1.5}
|
||||
style={{
|
||||
background: 'var(--color-error-muted, #fceeee)',
|
||||
borderRadius: 12,
|
||||
borderRadius: 8,
|
||||
color: 'var(--color-error, #d64545)',
|
||||
border: '1px solid var(--color-error-border, #fad1d1)',
|
||||
}}
|
||||
>
|
||||
<AlertCircle size={17} />
|
||||
<AlertCircle size={15} />
|
||||
<Text type="body" size="sm" style={{ color: 'inherit' }}>
|
||||
{error}
|
||||
</Text>
|
||||
</HStack>
|
||||
) : null}
|
||||
|
||||
<HStack justify="between" align="center" gap={2} wrap="wrap">
|
||||
{/* Sticky Bottom Actions */}
|
||||
<div
|
||||
style={{
|
||||
position: 'sticky',
|
||||
bottom: 0,
|
||||
zIndex: 20,
|
||||
padding: '12px 18px',
|
||||
background: 'rgba(255, 255, 255, 0.94)',
|
||||
backdropFilter: 'blur(8px)',
|
||||
border: '1px solid var(--color-line, #e0e4ea)',
|
||||
borderRadius: 10,
|
||||
boxShadow: '0 4px 16px -2px rgba(15, 23, 42, 0.08)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 12,
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
label="Cancel"
|
||||
onClick={() => navigate('/nearle/stores')}
|
||||
/>
|
||||
|
||||
<Button
|
||||
label={mutation.isPending ? 'Provisioning…' : 'Provision tenant'}
|
||||
type="submit"
|
||||
variant="primary"
|
||||
size="lg"
|
||||
label={mutation.isPending ? 'Saving…' : 'Save & Continue'}
|
||||
isLoading={mutation.isPending}
|
||||
isDisabled={!isComplete}
|
||||
/>
|
||||
</HStack>
|
||||
</div>
|
||||
</VStack>
|
||||
</form>
|
||||
</PageBody>
|
||||
|
||||
@@ -1,29 +1,7 @@
|
||||
/**
|
||||
* Rider partners — the companies that supply riders.
|
||||
*
|
||||
* ── Why this page did not exist ─────────────────────────────────────────────
|
||||
*
|
||||
* `getpartners` has always been readable and nothing on the platform could
|
||||
* create a partner, so the five that exist were inserted by hand — two are
|
||||
* still called "Test". Meanwhile 125 of 200 merchants already carry a
|
||||
* `partnerid`, and one partner supplies 48 shops while another supplies 63. The
|
||||
* relationship the whole delivery side rests on was real, live and unmanaged.
|
||||
*
|
||||
* ── What onboarding a partner records ───────────────────────────────────────
|
||||
*
|
||||
* Three things, and the last two are why the assign screen works at all:
|
||||
*
|
||||
* the district `partnerinfo.applocationid`, and `partnerlocations` beside
|
||||
* it. Every rider query joins through that id, so it has to be
|
||||
* a district Nearle actually services — see
|
||||
* `tamilNaduDistricts.ts` for why all 38 are shown anyway.
|
||||
* the merchant `tenants.partnerid`. This is what the assign screen reads to
|
||||
* decide whether to offer a partner tab at all.
|
||||
* the branch `tenantlocations.partnerid`. Which outlet they cover.
|
||||
*
|
||||
* A partner can also be attached to a merchant afterwards from that merchant's
|
||||
* own page — see `StoreDetailPage` — which is the ordinary case of a shop
|
||||
* changing partner without anybody re-onboarding the company.
|
||||
* Clean, modern SaaS management workspace for delivery partners, fleet tracking, and region coverage.
|
||||
*/
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
@@ -36,7 +14,12 @@ import { Table, type TableColumn } from '@astryxdesign/core/Table';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { TextInput } from '@astryxdesign/core/TextInput';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { Bike, Plus, Truck } from 'lucide-react';
|
||||
import {
|
||||
Bike,
|
||||
MapPin,
|
||||
Plus,
|
||||
Truck,
|
||||
} from 'lucide-react';
|
||||
import { errorMessage } from '@/api/client';
|
||||
import { partnersApi, type NewPartner, type Partner } from '@/api/deliveries';
|
||||
import { tenantsApi } from '@/api/tenants';
|
||||
@@ -70,15 +53,16 @@ interface PartnerRow extends Record<string, unknown> {
|
||||
riders: number | null;
|
||||
}
|
||||
|
||||
type StatusFilter = 'all' | 'active' | 'inactive';
|
||||
|
||||
export function PartnersPage() {
|
||||
const partners = useAllPartners();
|
||||
const regions = useAppRegions();
|
||||
const [editing, setEditing] = useState<Partner | 'new' | null>(null);
|
||||
/** The partner whose riders are on screen, if any. */
|
||||
const [ridersFor, setRidersFor] = useState<Partner | null>(null);
|
||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all');
|
||||
|
||||
/* The fleet size per partner, read alongside the directory. Without it the
|
||||
Riders button is a door with nothing written on it. */
|
||||
/* The fleet size per partner, read alongside the directory. */
|
||||
const riderCounts = usePartnerRiderCounts(partners.data.map((entry) => entry.partnerid));
|
||||
|
||||
const regionName = useMemo(() => {
|
||||
@@ -89,7 +73,7 @@ export function PartnersPage() {
|
||||
return map;
|
||||
}, [regions.data]);
|
||||
|
||||
const rows = useMemo<PartnerRow[]>(
|
||||
const rawRows = useMemo<PartnerRow[]>(
|
||||
() =>
|
||||
partners.data.map((partner) => ({
|
||||
partnerid: partner.partnerid,
|
||||
@@ -103,34 +87,81 @@ export function PartnersPage() {
|
||||
[partners.data, regionName, riderCounts],
|
||||
);
|
||||
|
||||
const paged = usePaged(rows);
|
||||
// Filtered rows based on status filter
|
||||
const filteredRows = useMemo(() => {
|
||||
if (statusFilter === 'all') return rawRows;
|
||||
return rawRows.filter((row) =>
|
||||
statusFilter === 'active'
|
||||
? row.status.toLowerCase() === 'active'
|
||||
: row.status.toLowerCase() !== 'active',
|
||||
);
|
||||
}, [rawRows, statusFilter]);
|
||||
|
||||
const paged = usePaged(filteredRows);
|
||||
|
||||
// KPI Calculations
|
||||
const totals = useMemo(() => {
|
||||
const totalPartners = rawRows.length;
|
||||
const activePartners = rawRows.filter((r) => r.status.toLowerCase() === 'active').length;
|
||||
const totalRiders = rawRows.reduce((sum, r) => sum + (r.riders ?? 0), 0);
|
||||
const uniqueDistricts = new Set(
|
||||
rawRows.map((r) => r.region).filter((reg) => reg && reg !== '—'),
|
||||
).size;
|
||||
|
||||
return { totalPartners, activePartners, totalRiders, uniqueDistricts };
|
||||
}, [rawRows]);
|
||||
|
||||
const columns: TableColumn<PartnerRow>[] = [
|
||||
{
|
||||
key: 'partnername',
|
||||
header: 'Partner',
|
||||
width: { type: 'proportional', value: 3 },
|
||||
renderCell: (row) => (
|
||||
<VStack gap={0}>
|
||||
<Text type="label" size="sm" weight="semibold">
|
||||
{row.partnername}
|
||||
</Text>
|
||||
{row.companyname ? (
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
{row.companyname}
|
||||
</Text>
|
||||
) : null}
|
||||
</VStack>
|
||||
),
|
||||
width: { type: 'proportional', value: 3.5 },
|
||||
renderCell: (row) => {
|
||||
const initial = (row.partnername || 'P')[0]?.toUpperCase() ?? 'P';
|
||||
return (
|
||||
<HStack align="center" gap={1.5}>
|
||||
<div
|
||||
style={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 8,
|
||||
background: 'var(--color-brand-tint, #f4eef8)',
|
||||
color: 'var(--color-brand, #662582)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontWeight: 700,
|
||||
fontSize: 13,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{initial}
|
||||
</div>
|
||||
<VStack gap={0}>
|
||||
<Text type="label" size="sm" weight="semibold">
|
||||
{row.partnername}
|
||||
</Text>
|
||||
{row.companyname ? (
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
{row.companyname}
|
||||
</Text>
|
||||
) : null}
|
||||
</VStack>
|
||||
</HStack>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'region',
|
||||
header: 'Home region',
|
||||
width: { type: 'proportional', value: 2 },
|
||||
renderCell: (row) => (
|
||||
<Text type="body" size="sm" color="secondary">
|
||||
{row.region}
|
||||
</Text>
|
||||
<HStack align="center" gap={0.5}>
|
||||
<MapPin size={13} style={{ color: 'var(--color-ink-4, #97a1b0)' }} />
|
||||
<Text type="body" size="sm" color="secondary">
|
||||
{row.region}
|
||||
</Text>
|
||||
</HStack>
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -156,21 +187,13 @@ export function PartnersPage() {
|
||||
),
|
||||
},
|
||||
{
|
||||
/* Both actions in ONE column with a header, rather than two unlabelled
|
||||
ones. The riders button carries the fleet size, because "Riders" alone
|
||||
asks you to open a drawer to learn whether there are any — and the
|
||||
answer is the reason you would open it. */
|
||||
key: 'actions',
|
||||
header: 'Fleet',
|
||||
align: 'end',
|
||||
width: { type: 'pixel', value: 184 },
|
||||
width: { type: 'pixel', value: 190 },
|
||||
renderCell: (row) => (
|
||||
<HStack gap={0.5} justify="end" align="center" className="fleet-actions">
|
||||
<HStack gap={0.5} justify="end" align="center">
|
||||
<Button
|
||||
/* One width for every row, fixed in CSS. The label is a count, so
|
||||
it runs from one digit to three and a shrink-to-fit button leaves
|
||||
the column ragged — an uneven edge reads as disorder before the
|
||||
numbers themselves are read. */
|
||||
label={row.riders === null ? '—' : `${row.riders} rider${row.riders === 1 ? '' : 's'}`}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
@@ -198,6 +221,29 @@ export function PartnersPage() {
|
||||
<VStack gap={3}>
|
||||
<PageHeader
|
||||
title="Rider partners"
|
||||
isTabsInline
|
||||
tabs={
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<FilterTab
|
||||
label="All partners"
|
||||
count={rawRows.length}
|
||||
isActive={statusFilter === 'all'}
|
||||
onClick={() => setStatusFilter('all')}
|
||||
/>
|
||||
<FilterTab
|
||||
label="Active"
|
||||
count={totals.activePartners}
|
||||
isActive={statusFilter === 'active'}
|
||||
onClick={() => setStatusFilter('active')}
|
||||
/>
|
||||
<FilterTab
|
||||
label="Inactive"
|
||||
count={rawRows.length - totals.activePartners}
|
||||
isActive={statusFilter === 'inactive'}
|
||||
onClick={() => setStatusFilter('inactive')}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
actions={
|
||||
<Button
|
||||
label="Onboard rider partner"
|
||||
@@ -209,33 +255,30 @@ export function PartnersPage() {
|
||||
}
|
||||
/>
|
||||
|
||||
<VStack gap={1.5}>
|
||||
{/* No section heading. The page is already titled "Rider partners" and
|
||||
the table is the only thing on it — a second heading over one table
|
||||
restates the page and pushes the rows down a row for nothing. */}
|
||||
<Card padding={0} elevation="low">
|
||||
<DataState
|
||||
isLoading={partners.isLoading}
|
||||
error={null}
|
||||
isEmpty={rows.length === 0}
|
||||
emptyTitle="No rider partners yet"
|
||||
emptyDescription="A rider partner is the company that supplies riders. Onboard one, choose its district, and attach it to the shop and branch it delivers for."
|
||||
>
|
||||
<div className="table-scroll">
|
||||
<Table<PartnerRow>
|
||||
data={paged.rows}
|
||||
columns={columns}
|
||||
idKey="partnerid"
|
||||
density="balanced"
|
||||
hasHover
|
||||
dividers="rows"
|
||||
/>
|
||||
</div>
|
||||
<TablePager paged={paged} label="partners" />
|
||||
</DataState>
|
||||
</Card>
|
||||
</VStack>
|
||||
{/* Partners Table Card */}
|
||||
<Card padding={0} elevation="low" style={{ background: '#ffffff', borderRadius: 12 }}>
|
||||
<DataState
|
||||
isLoading={partners.isLoading}
|
||||
error={null}
|
||||
isEmpty={filteredRows.length === 0}
|
||||
emptyTitle="No rider partners yet"
|
||||
emptyDescription="A rider partner is the company that supplies delivery riders. Click Onboard rider partner to add your first partner."
|
||||
>
|
||||
<div className="table-scroll">
|
||||
<Table<PartnerRow>
|
||||
data={paged.rows}
|
||||
columns={columns}
|
||||
idKey="partnerid"
|
||||
density="balanced"
|
||||
hasHover
|
||||
dividers="rows"
|
||||
/>
|
||||
</div>
|
||||
<TablePager paged={paged} label="partners" />
|
||||
</DataState>
|
||||
</Card>
|
||||
|
||||
{/* Drawers */}
|
||||
{ridersFor ? (
|
||||
<PartnerRidersDrawer partner={ridersFor} onClose={() => setRidersFor(null)} />
|
||||
) : null}
|
||||
@@ -250,6 +293,54 @@ export function PartnersPage() {
|
||||
);
|
||||
}
|
||||
|
||||
/** Filter pill button */
|
||||
function FilterTab({
|
||||
label,
|
||||
count,
|
||||
isActive,
|
||||
onClick,
|
||||
}: {
|
||||
label: string;
|
||||
count: number;
|
||||
isActive: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
padding: '5px 12px',
|
||||
borderRadius: 999,
|
||||
border: `1px solid ${isActive ? 'var(--color-brand, #662582)' : 'var(--color-line, #e0e4ea)'}`,
|
||||
background: isActive ? 'var(--color-brand-tint, #f4eef8)' : '#ffffff',
|
||||
color: isActive ? 'var(--color-brand, #662582)' : 'var(--color-ink-2, #414b5a)',
|
||||
fontSize: 12.5,
|
||||
fontWeight: isActive ? 600 : 500,
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s ease',
|
||||
}}
|
||||
>
|
||||
<span>{label}</span>
|
||||
<span
|
||||
style={{
|
||||
padding: '1px 6px',
|
||||
borderRadius: 999,
|
||||
background: isActive ? 'var(--color-brand, #662582)' : 'var(--color-surface-sunken, #f2f4f7)',
|
||||
color: isActive ? '#ffffff' : 'var(--color-ink-3, #657081)',
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{count}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── The form ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
interface FormState {
|
||||
@@ -263,11 +354,8 @@ interface FormState {
|
||||
city: string;
|
||||
state: string;
|
||||
postcode: string;
|
||||
/** The serviced district they work out of — an `app_location` id. */
|
||||
applocationid: number;
|
||||
/** The merchant this partner delivers for. */
|
||||
tenantid: number;
|
||||
/** Which branch of that merchant — written to `tenantlocations.partnerid`. */
|
||||
locationid: number;
|
||||
}
|
||||
|
||||
@@ -320,10 +408,6 @@ function PartnerDrawer({ partner, onClose }: { partner: Partner | null; onClose:
|
||||
};
|
||||
}
|
||||
|
||||
/* ── The district ─────────────────────────────────────────────────────────
|
||||
One per partner, chosen from all 38. Picking one Nearle does not run yet
|
||||
opens it — the partner is sent with the NAME and the server writes the
|
||||
`app_location` and `app_locationconfig` rows first. */
|
||||
const [districtSearch, setDistrictSearch] = useState('');
|
||||
const [district, setDistrict] = useState<string>(() => partner?.city ?? '');
|
||||
const districts = useMemo(() => districtOptions(regions.data ?? []), [regions.data]);
|
||||
@@ -333,17 +417,6 @@ function PartnerDrawer({ partner, onClose }: { partner: Partner | null; onClose:
|
||||
);
|
||||
const chosenDistrict = districts.find((option) => option.name === district);
|
||||
|
||||
/* ── Who they deliver for ─────────────────────────────────────────────────
|
||||
A partner supplies riders TO a merchant's branch. Both links are written:
|
||||
`tenants.partnerid`, which is what the assign screen reads to decide
|
||||
whether to offer a partner tab at all, and `tenantlocations.partnerid`,
|
||||
which records the branch. Without the first the toggle never appears;
|
||||
without the second nothing says which outlet they cover.
|
||||
|
||||
Filtered to the district: a partner works one district, so a merchant in
|
||||
another is not somebody they can deliver for. `getalltenants` returns a row
|
||||
per BRANCH, and a branch's city is what places it — the tenant's own city
|
||||
is the head office and can differ. */
|
||||
const merchants = useTenants({ pageno: 1, pagesize: 200 });
|
||||
const merchantOptions = useMemo(() => {
|
||||
const here = district.trim().toLowerCase();
|
||||
@@ -380,11 +453,6 @@ function PartnerDrawer({ partner, onClose }: { partner: Partner | null; onClose:
|
||||
city: form.city.trim(),
|
||||
state: form.state.trim(),
|
||||
...(form.postcode.trim() ? { postcode: Number(form.postcode) || 0 } : {}),
|
||||
/*
|
||||
The district, by id when Nearle already runs it and by NAME when it
|
||||
does not. The name is what opens it — the server writes the region
|
||||
rows before the partner, so all 38 are real choices rather than three.
|
||||
*/
|
||||
applocationid: chosenDistrict?.applocationid ?? 0,
|
||||
...(chosenDistrict && chosenDistrict.applocationid === 0
|
||||
? { district: chosenDistrict.name }
|
||||
@@ -394,15 +462,6 @@ function PartnerDrawer({ partner, onClose }: { partner: Partner | null; onClose:
|
||||
? partnersApi.update({ ...body, partnerid: partner.partnerid }).then(() => partner.partnerid)
|
||||
: partnersApi.create(body).then((result) => result?.partnerid ?? 0);
|
||||
},
|
||||
/*
|
||||
The placement is written after the partner exists, because it needs the
|
||||
id the create hands back.
|
||||
|
||||
Reported separately if it fails, and deliberately not rolled back: the
|
||||
partner is real either way and re-onboarding them would refuse on the
|
||||
duplicate contact number. Saying "the partner was created but could not be
|
||||
placed" is recoverable — the drawer stays open on the same form.
|
||||
*/
|
||||
onSuccess: async (partnerid) => {
|
||||
if (partnerid > 0 && form.tenantid > 0) {
|
||||
try {
|
||||
@@ -490,13 +549,6 @@ function PartnerDrawer({ partner, onClose }: { partner: Partner | null; onClose:
|
||||
onChange={set('registrationno')}
|
||||
/>
|
||||
|
||||
{/* ── District ──────────────────────────────────────────────────────
|
||||
All 38 of Tamil Nadu's districts, searchable, with only the ones
|
||||
Nearle services selectable. A partner placed in a district that has
|
||||
no `app_location` row is a partner whose riders no query returns —
|
||||
`getriders` filters on that id — so an unserviced district is shown
|
||||
and refused rather than hidden, because "Erode is not open yet" is
|
||||
an answer and a missing Erode is not. */}
|
||||
<VStack gap={1}>
|
||||
<Text type="label" size="sm" weight="semibold">
|
||||
District
|
||||
@@ -510,10 +562,6 @@ function PartnerDrawer({ partner, onClose }: { partner: Partner | null; onClose:
|
||||
placeholder="Search all 38 districts…"
|
||||
hasClear
|
||||
/>
|
||||
{/* A single-select list, not a cloud of chips: one partner works one
|
||||
district, so this is a choice with one answer and it should read
|
||||
like one. Running districts carry a tick, new ones say what will
|
||||
happen — the difference is operational, not a restriction. */}
|
||||
<div className="district-list" role="listbox" aria-label="Tamil Nadu districts">
|
||||
{shown.map((option) => {
|
||||
const running = isRunning(option);
|
||||
@@ -528,9 +576,6 @@ function PartnerDrawer({ partner, onClose }: { partner: Partner | null; onClose:
|
||||
data-chosen={chosen ? 'yes' : 'no'}
|
||||
onClick={() => {
|
||||
setDistrict(option.name);
|
||||
// The merchant is district-scoped, so changing the district
|
||||
// invalidates it — keeping it would attach a partner to a
|
||||
// shop in a place they do not work.
|
||||
setForm((prev) => ({ ...prev, tenantid: 0, locationid: 0 }));
|
||||
}}
|
||||
>
|
||||
@@ -556,10 +601,6 @@ function PartnerDrawer({ partner, onClose }: { partner: Partner | null; onClose:
|
||||
</Text>
|
||||
</VStack>
|
||||
|
||||
{/* ── Who they deliver for ──────────────────────────────────────────
|
||||
The merchant, then the branch. Both links are written: the merchant
|
||||
one is what the assign screen reads to decide whether to offer a
|
||||
partner tab at all, and the branch one records which outlet. */}
|
||||
<VStack gap={1}>
|
||||
<Text type="label" size="sm" weight="semibold">
|
||||
Delivers for
|
||||
@@ -569,8 +610,6 @@ function PartnerDrawer({ partner, onClose }: { partner: Partner | null; onClose:
|
||||
size="sm"
|
||||
value={form.tenantid ? String(form.tenantid) : ''}
|
||||
onChange={(value) => {
|
||||
// A new merchant clears the branch with it — keeping it would
|
||||
// leave another shop's outlet id attached to this partner.
|
||||
setForm((prev) => ({ ...prev, tenantid: Number(value) || 0, locationid: 0 }));
|
||||
}}
|
||||
options={merchantOptions}
|
||||
|
||||
Reference in New Issue
Block a user