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

View File

@@ -1,82 +1,21 @@
import { useSearchParams } from 'react-router-dom';
import { SegmentedControl, SegmentedControlItem } from '@astryxdesign/core/SegmentedControl';
import { VStack } from '@astryxdesign/core/VStack';
import { PackageSearch, Upload } from 'lucide-react';
import { PageHeader } from '@/components/PageHeader';
import { CatalogueBrowser } from '@/features/catalogue/CatalogueBrowser';
import { SheetImportPanel } from '../import/SheetImportPanel';
/**
* The platform operator's catalogue.
*
* TWO MODES, AND THEY ASK FOR DIFFERENT THINGS.
*
* - **Browse** is reading. The operator looks at what the FMCG catalogue holds
* — the photographs, the pack sizes, the FSSAI licences, what is stocked
* where — and nothing is written. There is no merchant to choose because
* nothing lands anywhere, so the page is the rail, the search and the grid
* and nothing else. The per-product Add is gone from here: stocking one
* shop at a time is the merchant's own job, in Store Admin ▸ Inventory ▸
* Catalogue.
*
* - **Upload sheet** is writing, and it writes the GLOBAL catalogue — not one
* merchant's shelf. The ingest service parses, enriches and stores rows in
* the per-brand tables every merchant reads from, and it takes no tenant and
* no outlet. This mode therefore asks for nothing but a file.
*
* The grid, the filters, the cards and the detail drawer are the shared
* `CatalogueBrowser` — the same ones the Store Admin sees, because it is the
* same catalogue.
*/
export function GlobalCataloguePage() {
/*
The tab is in the URL so it can be linked to.
Uploads has an "Upload spreadsheet" button and this is where that action
lives for a platform operator — it writes the GLOBAL catalogue, which is not
the same action as a merchant uploading their own list. Held in local state it
could only be reached by landing on Browse and pressing a second control,
which is a detour rather than a flow.
*/
const [params, setParams] = useSearchParams();
const [params] = useSearchParams();
const mode = params.get('tab') === 'sheet' ? 'sheet' : 'catalogue';
const setMode = (next: 'catalogue' | 'sheet') => {
const merged = new URLSearchParams(params);
if (next === 'catalogue') merged.delete('tab');
else merged.set('tab', next);
setParams(merged, { replace: true });
};
return (
<VStack gap={3}>
<PageHeader
title="Global catalogue"
actions={
<SegmentedControl
label="What to do"
value={mode}
onChange={(value) => setMode(value as 'catalogue' | 'sheet')}
size="sm"
>
<SegmentedControlItem value="catalogue" label="Browse catalogue" icon={<PackageSearch size={14} />} />
<SegmentedControlItem value="sheet" label="Upload sheet" icon={<Upload size={14} />} />
</SegmentedControl>
}
/>
<PageHeader title="Global catalogue" />
{mode === 'sheet' ? (
/* The merchant and outlet pickers used to sit above this panel, and
they described a flow that no longer exists.
The ingest endpoint writes the GLOBAL catalogue. It has no concept of
a tenant or an outlet — putting a product on one shop's shelf with a
price and opening stock is a separate call (`/api/upload/stores`,
joined on `image_id`) that is not wired up yet. Two selectors saying
the upload was "written against one merchant and one outlet" would
have had someone pick a shop, upload, and then go looking for stock
that was never going to arrive.
They come back with the inventory step, and mean something then. */
<SheetImportPanel />
) : (
<CatalogueBrowser

View File

@@ -8,12 +8,16 @@ import { Selector } from '@astryxdesign/core/Selector';
import { Text } from '@astryxdesign/core/Text';
import { TextInput } from '@astryxdesign/core/TextInput';
import { VStack } from '@astryxdesign/core/VStack';
import { AlertCircle, Building2, CheckCircle2, MapPin } from 'lucide-react';
import {
AlertCircle,
ArrowLeft,
Building2,
CheckCircle2,
MapPin,
} from 'lucide-react';
import { tenantsApi, type CreateTenantRequest } from '@/api/tenants';
import { errorMessage } from '@/api/client';
import { PageBody } from '@/components/PageBody';
import { PageHeader } from '@/components/PageHeader';
import { SectionHeader } from '@/components/SectionHeader';
import { queryKeys } from '@/queries/keys';
import { StoreQrPanel } from '@/features/qr/StoreQrPanel';
import { useAppCategories } from '@/queries/hooks';
@@ -21,7 +25,6 @@ import { useAppCategories } from '@/queries/hooks';
interface FormState {
tenantname: string;
companyname: string;
/** Who runs the shop. Written to `tenants.firstname` — see `CreateTenantRequest`. */
adminname: string;
primarycontact: string;
primaryemail: string;
@@ -41,45 +44,23 @@ const EMPTY: FormState = {
primarycontact: '',
primaryemail: '',
locationname: '',
// No default category. `utils/getappcategories` supplies the real list and
// this used to pre-select the first id regardless of what the merchant sells.
categoryid: '',
address: '',
suburb: '',
// Empty, not pre-filled.
//
// These carried 'Coimbatore' and 'Tamil Nadu' as VALUES, not placeholders —
// so a merchant anywhere else was submitted with the wrong city and state
// unless somebody noticed and cleared them. A default that is right most of
// the time is worse than a blank field, because it is only checked when it
// looks wrong.
city: '',
state: '',
postcode: '',
};
/**
* Provision a new merchant tenant.
* Onboard Tenant Page
*
* This registers the enterprise, its first outlet, and spawns the primary
* Administrator account — account creation is a side effect of provisioning
* here, because that is what the backend does. There is no separate invite.
*
* Products are NOT collected on this form. Once the tenant exists it has a
* tenantid and a locationid, and only then can either import path run: both
* `importcatalogueproduct` and `createproductlocation` require the pair. So the
* form hands off to the catalogue on success rather than pretending the two
* steps are one.
* Clean, standard enterprise SaaS onboarding experience using native design system inputs and components.
*/
export function OnboardTenantPage() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const [form, setForm] = useState<FormState>(EMPTY);
/**
* The category list comes from `app_category`, not from four values typed
* into this file. A category added to the master should appear here without
* a release.
*/
const categories = useAppCategories();
const [error, setError] = useState<string | null>(null);
@@ -110,6 +91,8 @@ export function OnboardTenantPage() {
function handleSubmit(event: FormEvent) {
event.preventDefault();
if (!isComplete || mutation.isPending) return;
setError(null);
mutation.mutate({
tenantname: form.tenantname.trim(),
@@ -118,8 +101,6 @@ export function OnboardTenantPage() {
primarycontact: form.primarycontact.trim(),
primaryemail: form.primaryemail.trim(),
locationname: form.locationname.trim(),
// `Number('')` is NaN, which serialises to null and is not what the
// backend means by "uncategorised" — 0 is.
categoryid: Number(form.categoryid) || 0,
address: form.address.trim(),
suburb: form.suburb.trim(),
@@ -133,121 +114,190 @@ export function OnboardTenantPage() {
if (mutation.isSuccess) {
const created = mutation.data;
return (
<VStack gap={3}>
<PageHeader
title="Tenant provisioned"
/>
<Card padding={4} elevation="low">
<VStack gap={3}>
<HStack align="center" gap={1.5}>
<CheckCircle2 size={22} style={{ color: 'var(--color-success, #10b981)' }} />
<Text type="large" weight="semibold">
{form.tenantname} is live
<PageBody measure="reading">
<VStack gap={3}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<button
type="button"
onClick={() => navigate('/nearle/stores')}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 6,
background: 'transparent',
border: 'none',
padding: '4px 0',
fontSize: 13,
fontWeight: 500,
color: 'var(--color-ink-3, #657081)',
cursor: 'pointer',
}}
>
<ArrowLeft size={14} />
<span>Back to stores</span>
</button>
</div>
<Card padding={4} elevation="low">
<VStack gap={3}>
<HStack align="center" gap={1.5}>
<CheckCircle2 size={24} style={{ color: 'var(--color-success, #10b981)' }} />
<Text type="large" weight="semibold">
{form.tenantname} is live
</Text>
</HStack>
<Text type="body" color="secondary">
Its first outlet, {form.locationname}, has been commissioned. The next step is
stocking the catalogue — pick products from the global catalogue, or upload the
tenant&apos;s own list as a spreadsheet.
</Text>
</HStack>
<Text type="body" color="secondary">
Its first outlet, {form.locationname}, has been commissioned. The next step is
stocking the catalogue — pick products from the global catalogue, or upload the
tenant&apos;s own list as a spreadsheet.
</Text>
{/* The storefront code, at the one moment the person who provisioned
the shop is holding its details.
It was reachable only from the branch user's own header, which
is the wrong place for it: the code is what puts the store in
front of a shopper at all — nobody can order from a shop they
have not scanned — and the person onboarding it is the one who
sends it to the merchant. `createtenantuser` returns the tenant
and its primary outlet's id together, so it can be drawn here
without a second read. */}
{created?.tenantid && created?.locationid ? (
<Card padding={3} elevation="low" variant="transparent">
<StoreQrPanel
tenantid={created.tenantid}
locationid={created.locationid}
locationname={form.locationname}
where={[form.suburb, form.city].filter(Boolean).join(', ')}
{created?.tenantid && created?.locationid ? (
<Card padding={3} elevation="low" variant="transparent">
<StoreQrPanel
tenantid={created.tenantid}
locationid={created.locationid}
locationname={form.locationname}
where={[form.suburb, form.city].filter(Boolean).join(', ')}
/>
</Card>
) : null}
<HStack gap={1.5} wrap="wrap">
<Button
label="Stock from global catalogue"
variant="primary"
onClick={() =>
navigate(
`/nearle/catalogue?tenantid=${created?.tenantid ?? ''}&locationid=${created?.locationid ?? ''}`,
)
}
/>
</Card>
) : null}
<HStack gap={1.5} wrap="wrap">
<Button
label="Stock from global catalogue"
variant="primary"
onClick={() =>
navigate(
`/nearle/catalogue?tenantid=${created?.tenantid ?? ''}&locationid=${created?.locationid ?? ''}`,
)
}
/>
<Button
label="Back to stores"
variant="secondary"
onClick={() => navigate('/nearle/stores')}
/>
</HStack>
</VStack>
</Card>
</VStack>
<Button
label="Back to stores"
variant="secondary"
onClick={() => navigate('/nearle/stores')}
/>
</HStack>
</VStack>
</Card>
</VStack>
</PageBody>
);
}
return (
<PageBody measure="reading">
<PageHeader
title="Onboard tenant"
/>
<form onSubmit={handleSubmit}>
<form onSubmit={handleSubmit} style={{ width: '100%' }}>
<VStack gap={3}>
{/* Top Back Action & Required Indicator */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 16,
}}
>
<h1 className="sr-only">Onboard Tenant</h1>
<button
type="button"
onClick={() => navigate('/nearle/stores')}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 5,
background: 'transparent',
border: 'none',
padding: '4px 0',
fontSize: 13,
fontWeight: 500,
color: 'var(--color-ink-3, #657081)',
cursor: 'pointer',
transition: 'color 0.15s ease',
}}
onMouseEnter={(e) => (e.currentTarget.style.color = 'var(--color-brand, #662582)')}
onMouseLeave={(e) => (e.currentTarget.style.color = 'var(--color-ink-3, #657081)')}
>
<ArrowLeft size={14} />
<span>Back to stores</span>
</button>
<div
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 4,
padding: '3px 8px',
borderRadius: 6,
background: 'var(--color-surface-sunken, #f2f4f7)',
border: '1px solid var(--color-line, #e0e4ea)',
fontSize: 11.5,
color: 'var(--color-ink-3, #657081)',
fontWeight: 500,
}}
>
<span>Fields marked with <span style={{ color: 'var(--color-error, #d64545)', fontWeight: 600 }}>*</span> are required</span>
</div>
</div>
{/* Section 1: Business Details Card */}
<Card padding={0} elevation="low">
<VStack gap={2} padding={3}>
<SectionHeader
title="Business"
action={<Building2 size={17} style={{ color: 'var(--color-slate-400)' }} />}
/>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
paddingBottom: 10,
borderBottom: '1px solid var(--color-line, #e0e4ea)',
}}
>
<Building2 size={16} style={{ color: 'var(--color-brand, #662582)' }} />
<span
style={{
fontSize: 14,
fontWeight: 600,
color: 'var(--color-ink-1, #0f172a)',
}}
>
Business Details
</span>
</div>
<div className="form-grid">
<TextInput
label={<span>Merchant name <span style={{ color: 'var(--color-error)' }}>*</span></span> as any}
label={<span>Merchant name <span style={{ color: 'var(--color-error, #d64545)' }}>*</span></span> as any}
value={form.tenantname}
onChange={set('tenantname')}
placeholder="e.g. Kaveri Groceries"
/>
<TextInput
label={<span>Company registered name <span style={{ color: 'var(--color-error)' }}>*</span></span> as any}
label={<span>Company registered name <span style={{ color: 'var(--color-error, #d64545)' }}>*</span></span> as any}
value={form.companyname}
onChange={set('companyname')}
placeholder="e.g. Kaveri Retail Pvt. Ltd."
/>
{/* Asked here because here is the only place it can be asked.
The primary outlet and the merchant's own admin login are
both created inside this one call, and the login is copied
from the tenant row — so the name given here names the
business AND the account that signs in. Left out, both are
blank, which is the state every merchant is in today. */}
<TextInput
label={<span>Store admin <span style={{ color: 'var(--color-error)' }}>*</span></span> as any}
label={<span>Store admin <span style={{ color: 'var(--color-error, #d64545)' }}>*</span></span> as any}
value={form.adminname}
onChange={set('adminname')}
placeholder="e.g. Ravi Kumar"
description="The person who administers this shop. Their name goes on the store profile and on the admin login created with it — the account is provisioned as Admin, which is what this field is named after."
/>
<TextInput
label={<span>Primary phone <span style={{ color: 'var(--color-error)' }}>*</span></span> as any}
label={<span>Primary phone <span style={{ color: 'var(--color-error, #d64545)' }}>*</span></span> as any}
value={form.primarycontact}
onChange={set('primarycontact')}
placeholder="9876543210"
/>
<TextInput
label={<span>Primary admin email <span style={{ color: 'var(--color-error)' }}>*</span></span> as any}
label={<span>Primary admin email <span style={{ color: 'var(--color-error, #d64545)' }}>*</span></span> as any}
type="email"
value={form.primaryemail}
onChange={set('primaryemail')}
placeholder="admin@kaveri.com"
/>
<TextInput
label={<span>First outlet name <span style={{ color: 'var(--color-error)' }}>*</span></span> as any}
label={<span>First outlet name <span style={{ color: 'var(--color-error, #d64545)' }}>*</span></span> as any}
value={form.locationname}
onChange={set('locationname')}
placeholder="e.g. Kaveri RS Puram"
@@ -258,6 +308,7 @@ export function OnboardTenantPage() {
value: String(entry.categoryid),
label: entry.categoryname,
}))}
placeholder="Select category"
isDisabled={categories.isLoading}
value={form.categoryid}
onChange={set('categoryid')}
@@ -266,24 +317,58 @@ export function OnboardTenantPage() {
</VStack>
</Card>
{/* Section 2: Head Office Card */}
<Card padding={0} elevation="low">
<VStack gap={2} padding={3}>
<SectionHeader
title="Head office"
action={<MapPin size={17} style={{ color: 'var(--color-slate-400)' }} />}
/>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
paddingBottom: 10,
borderBottom: '1px solid var(--color-line, #e0e4ea)',
}}
>
<MapPin size={16} style={{ color: 'var(--color-brand, #662582)' }} />
<span
style={{
fontSize: 14,
fontWeight: 600,
color: 'var(--color-ink-1, #0f172a)',
}}
>
Head Office
</span>
</div>
<TextInput
label={<span>Street address <span style={{ color: 'var(--color-error)' }}>*</span></span> as any}
label={<span>Street address <span style={{ color: 'var(--color-error, #d64545)' }}>*</span></span> as any}
value={form.address}
onChange={set('address')}
placeholder="e.g. 12, Avinashi Road"
/>
<div className="form-grid-4">
<TextInput label="Suburb" value={form.suburb} onChange={set('suburb')} placeholder="e.g. Peelamedu" />
<TextInput label={<span>City <span style={{ color: 'var(--color-error)' }}>*</span></span> as any} value={form.city} onChange={set('city')} />
<TextInput label={<span>State <span style={{ color: 'var(--color-error)' }}>*</span></span> as any} value={form.state} onChange={set('state')} />
<TextInput
label={<span>Postcode <span style={{ color: 'var(--color-error)' }}>*</span></span> as any}
label="Suburb"
value={form.suburb}
onChange={set('suburb')}
placeholder="e.g. Peelamedu"
/>
<TextInput
label={<span>City <span style={{ color: 'var(--color-error, #d64545)' }}>*</span></span> as any}
value={form.city}
onChange={set('city')}
placeholder="e.g. Coimbatore"
/>
<TextInput
label={<span>State <span style={{ color: 'var(--color-error, #d64545)' }}>*</span></span> as any}
value={form.state}
onChange={set('state')}
placeholder="e.g. Tamil Nadu"
/>
<TextInput
label={<span>Postcode <span style={{ color: 'var(--color-error, #d64545)' }}>*</span></span> as any}
value={form.postcode}
onChange={set('postcode')}
placeholder="641004"
@@ -292,34 +377,60 @@ export function OnboardTenantPage() {
</VStack>
</Card>
{/* Error Alert */}
{error ? (
<HStack
align="center"
gap={1}
padding={2}
padding={1.5}
style={{
background: 'var(--color-error-muted, #fceeee)',
borderRadius: 12,
borderRadius: 8,
color: 'var(--color-error, #d64545)',
border: '1px solid var(--color-error-border, #fad1d1)',
}}
>
<AlertCircle size={17} />
<AlertCircle size={15} />
<Text type="body" size="sm" style={{ color: 'inherit' }}>
{error}
</Text>
</HStack>
) : null}
<HStack justify="between" align="center" gap={2} wrap="wrap">
{/* Sticky Bottom Actions */}
<div
style={{
position: 'sticky',
bottom: 0,
zIndex: 20,
padding: '12px 18px',
background: 'rgba(255, 255, 255, 0.94)',
backdropFilter: 'blur(8px)',
border: '1px solid var(--color-line, #e0e4ea)',
borderRadius: 10,
boxShadow: '0 4px 16px -2px rgba(15, 23, 42, 0.08)',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 12,
flexWrap: 'wrap',
}}
>
<Button
type="button"
variant="secondary"
label="Cancel"
onClick={() => navigate('/nearle/stores')}
/>
<Button
label={mutation.isPending ? 'Provisioning…' : 'Provision tenant'}
type="submit"
variant="primary"
size="lg"
label={mutation.isPending ? 'Saving…' : 'Save & Continue'}
isLoading={mutation.isPending}
isDisabled={!isComplete}
/>
</HStack>
</div>
</VStack>
</form>
</PageBody>

View File

@@ -1,29 +1,7 @@
/**
* Rider partners — the companies that supply riders.
*
* ── Why this page did not exist ─────────────────────────────────────────────
*
* `getpartners` has always been readable and nothing on the platform could
* create a partner, so the five that exist were inserted by hand — two are
* still called "Test". Meanwhile 125 of 200 merchants already carry a
* `partnerid`, and one partner supplies 48 shops while another supplies 63. The
* relationship the whole delivery side rests on was real, live and unmanaged.
*
* ── What onboarding a partner records ───────────────────────────────────────
*
* Three things, and the last two are why the assign screen works at all:
*
* the district `partnerinfo.applocationid`, and `partnerlocations` beside
* it. Every rider query joins through that id, so it has to be
* a district Nearle actually services — see
* `tamilNaduDistricts.ts` for why all 38 are shown anyway.
* the merchant `tenants.partnerid`. This is what the assign screen reads to
* decide whether to offer a partner tab at all.
* the branch `tenantlocations.partnerid`. Which outlet they cover.
*
* A partner can also be attached to a merchant afterwards from that merchant's
* own page — see `StoreDetailPage` — which is the ordinary case of a shop
* changing partner without anybody re-onboarding the company.
* Clean, modern SaaS management workspace for delivery partners, fleet tracking, and region coverage.
*/
import { useMemo, useState } from 'react';
@@ -36,7 +14,12 @@ import { Table, type TableColumn } from '@astryxdesign/core/Table';
import { Text } from '@astryxdesign/core/Text';
import { TextInput } from '@astryxdesign/core/TextInput';
import { VStack } from '@astryxdesign/core/VStack';
import { Bike, Plus, Truck } from 'lucide-react';
import {
Bike,
MapPin,
Plus,
Truck,
} from 'lucide-react';
import { errorMessage } from '@/api/client';
import { partnersApi, type NewPartner, type Partner } from '@/api/deliveries';
import { tenantsApi } from '@/api/tenants';
@@ -70,15 +53,16 @@ interface PartnerRow extends Record<string, unknown> {
riders: number | null;
}
type StatusFilter = 'all' | 'active' | 'inactive';
export function PartnersPage() {
const partners = useAllPartners();
const regions = useAppRegions();
const [editing, setEditing] = useState<Partner | 'new' | null>(null);
/** The partner whose riders are on screen, if any. */
const [ridersFor, setRidersFor] = useState<Partner | null>(null);
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all');
/* The fleet size per partner, read alongside the directory. Without it the
Riders button is a door with nothing written on it. */
/* The fleet size per partner, read alongside the directory. */
const riderCounts = usePartnerRiderCounts(partners.data.map((entry) => entry.partnerid));
const regionName = useMemo(() => {
@@ -89,7 +73,7 @@ export function PartnersPage() {
return map;
}, [regions.data]);
const rows = useMemo<PartnerRow[]>(
const rawRows = useMemo<PartnerRow[]>(
() =>
partners.data.map((partner) => ({
partnerid: partner.partnerid,
@@ -103,34 +87,81 @@ export function PartnersPage() {
[partners.data, regionName, riderCounts],
);
const paged = usePaged(rows);
// Filtered rows based on status filter
const filteredRows = useMemo(() => {
if (statusFilter === 'all') return rawRows;
return rawRows.filter((row) =>
statusFilter === 'active'
? row.status.toLowerCase() === 'active'
: row.status.toLowerCase() !== 'active',
);
}, [rawRows, statusFilter]);
const paged = usePaged(filteredRows);
// KPI Calculations
const totals = useMemo(() => {
const totalPartners = rawRows.length;
const activePartners = rawRows.filter((r) => r.status.toLowerCase() === 'active').length;
const totalRiders = rawRows.reduce((sum, r) => sum + (r.riders ?? 0), 0);
const uniqueDistricts = new Set(
rawRows.map((r) => r.region).filter((reg) => reg && reg !== '—'),
).size;
return { totalPartners, activePartners, totalRiders, uniqueDistricts };
}, [rawRows]);
const columns: TableColumn<PartnerRow>[] = [
{
key: 'partnername',
header: 'Partner',
width: { type: 'proportional', value: 3 },
renderCell: (row) => (
<VStack gap={0}>
<Text type="label" size="sm" weight="semibold">
{row.partnername}
</Text>
{row.companyname ? (
<Text type="body" size="xsm" color="secondary">
{row.companyname}
</Text>
) : null}
</VStack>
),
width: { type: 'proportional', value: 3.5 },
renderCell: (row) => {
const initial = (row.partnername || 'P')[0]?.toUpperCase() ?? 'P';
return (
<HStack align="center" gap={1.5}>
<div
style={{
width: 32,
height: 32,
borderRadius: 8,
background: 'var(--color-brand-tint, #f4eef8)',
color: 'var(--color-brand, #662582)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontWeight: 700,
fontSize: 13,
flexShrink: 0,
}}
>
{initial}
</div>
<VStack gap={0}>
<Text type="label" size="sm" weight="semibold">
{row.partnername}
</Text>
{row.companyname ? (
<Text type="body" size="xsm" color="secondary">
{row.companyname}
</Text>
) : null}
</VStack>
</HStack>
);
},
},
{
key: 'region',
header: 'Home region',
width: { type: 'proportional', value: 2 },
renderCell: (row) => (
<Text type="body" size="sm" color="secondary">
{row.region}
</Text>
<HStack align="center" gap={0.5}>
<MapPin size={13} style={{ color: 'var(--color-ink-4, #97a1b0)' }} />
<Text type="body" size="sm" color="secondary">
{row.region}
</Text>
</HStack>
),
},
{
@@ -156,21 +187,13 @@ export function PartnersPage() {
),
},
{
/* Both actions in ONE column with a header, rather than two unlabelled
ones. The riders button carries the fleet size, because "Riders" alone
asks you to open a drawer to learn whether there are any — and the
answer is the reason you would open it. */
key: 'actions',
header: 'Fleet',
align: 'end',
width: { type: 'pixel', value: 184 },
width: { type: 'pixel', value: 190 },
renderCell: (row) => (
<HStack gap={0.5} justify="end" align="center" className="fleet-actions">
<HStack gap={0.5} justify="end" align="center">
<Button
/* One width for every row, fixed in CSS. The label is a count, so
it runs from one digit to three and a shrink-to-fit button leaves
the column ragged — an uneven edge reads as disorder before the
numbers themselves are read. */
label={row.riders === null ? '—' : `${row.riders} rider${row.riders === 1 ? '' : 's'}`}
variant="secondary"
size="sm"
@@ -198,6 +221,29 @@ export function PartnersPage() {
<VStack gap={3}>
<PageHeader
title="Rider partners"
isTabsInline
tabs={
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<FilterTab
label="All partners"
count={rawRows.length}
isActive={statusFilter === 'all'}
onClick={() => setStatusFilter('all')}
/>
<FilterTab
label="Active"
count={totals.activePartners}
isActive={statusFilter === 'active'}
onClick={() => setStatusFilter('active')}
/>
<FilterTab
label="Inactive"
count={rawRows.length - totals.activePartners}
isActive={statusFilter === 'inactive'}
onClick={() => setStatusFilter('inactive')}
/>
</div>
}
actions={
<Button
label="Onboard rider partner"
@@ -209,33 +255,30 @@ export function PartnersPage() {
}
/>
<VStack gap={1.5}>
{/* No section heading. The page is already titled "Rider partners" and
the table is the only thing on it — a second heading over one table
restates the page and pushes the rows down a row for nothing. */}
<Card padding={0} elevation="low">
<DataState
isLoading={partners.isLoading}
error={null}
isEmpty={rows.length === 0}
emptyTitle="No rider partners yet"
emptyDescription="A rider partner is the company that supplies riders. Onboard one, choose its district, and attach it to the shop and branch it delivers for."
>
<div className="table-scroll">
<Table<PartnerRow>
data={paged.rows}
columns={columns}
idKey="partnerid"
density="balanced"
hasHover
dividers="rows"
/>
</div>
<TablePager paged={paged} label="partners" />
</DataState>
</Card>
</VStack>
{/* Partners Table Card */}
<Card padding={0} elevation="low" style={{ background: '#ffffff', borderRadius: 12 }}>
<DataState
isLoading={partners.isLoading}
error={null}
isEmpty={filteredRows.length === 0}
emptyTitle="No rider partners yet"
emptyDescription="A rider partner is the company that supplies delivery riders. Click Onboard rider partner to add your first partner."
>
<div className="table-scroll">
<Table<PartnerRow>
data={paged.rows}
columns={columns}
idKey="partnerid"
density="balanced"
hasHover
dividers="rows"
/>
</div>
<TablePager paged={paged} label="partners" />
</DataState>
</Card>
{/* Drawers */}
{ridersFor ? (
<PartnerRidersDrawer partner={ridersFor} onClose={() => setRidersFor(null)} />
) : null}
@@ -250,6 +293,54 @@ export function PartnersPage() {
);
}
/** Filter pill button */
function FilterTab({
label,
count,
isActive,
onClick,
}: {
label: string;
count: number;
isActive: boolean;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 6,
padding: '5px 12px',
borderRadius: 999,
border: `1px solid ${isActive ? 'var(--color-brand, #662582)' : 'var(--color-line, #e0e4ea)'}`,
background: isActive ? 'var(--color-brand-tint, #f4eef8)' : '#ffffff',
color: isActive ? 'var(--color-brand, #662582)' : 'var(--color-ink-2, #414b5a)',
fontSize: 12.5,
fontWeight: isActive ? 600 : 500,
cursor: 'pointer',
transition: 'all 0.15s ease',
}}
>
<span>{label}</span>
<span
style={{
padding: '1px 6px',
borderRadius: 999,
background: isActive ? 'var(--color-brand, #662582)' : 'var(--color-surface-sunken, #f2f4f7)',
color: isActive ? '#ffffff' : 'var(--color-ink-3, #657081)',
fontSize: 11,
fontWeight: 600,
}}
>
{count}
</span>
</button>
);
}
/* ── The form ─────────────────────────────────────────────────────────────── */
interface FormState {
@@ -263,11 +354,8 @@ interface FormState {
city: string;
state: string;
postcode: string;
/** The serviced district they work out of — an `app_location` id. */
applocationid: number;
/** The merchant this partner delivers for. */
tenantid: number;
/** Which branch of that merchant — written to `tenantlocations.partnerid`. */
locationid: number;
}
@@ -320,10 +408,6 @@ function PartnerDrawer({ partner, onClose }: { partner: Partner | null; onClose:
};
}
/* ── The district ─────────────────────────────────────────────────────────
One per partner, chosen from all 38. Picking one Nearle does not run yet
opens it — the partner is sent with the NAME and the server writes the
`app_location` and `app_locationconfig` rows first. */
const [districtSearch, setDistrictSearch] = useState('');
const [district, setDistrict] = useState<string>(() => partner?.city ?? '');
const districts = useMemo(() => districtOptions(regions.data ?? []), [regions.data]);
@@ -333,17 +417,6 @@ function PartnerDrawer({ partner, onClose }: { partner: Partner | null; onClose:
);
const chosenDistrict = districts.find((option) => option.name === district);
/* ── Who they deliver for ─────────────────────────────────────────────────
A partner supplies riders TO a merchant's branch. Both links are written:
`tenants.partnerid`, which is what the assign screen reads to decide
whether to offer a partner tab at all, and `tenantlocations.partnerid`,
which records the branch. Without the first the toggle never appears;
without the second nothing says which outlet they cover.
Filtered to the district: a partner works one district, so a merchant in
another is not somebody they can deliver for. `getalltenants` returns a row
per BRANCH, and a branch's city is what places it — the tenant's own city
is the head office and can differ. */
const merchants = useTenants({ pageno: 1, pagesize: 200 });
const merchantOptions = useMemo(() => {
const here = district.trim().toLowerCase();
@@ -380,11 +453,6 @@ function PartnerDrawer({ partner, onClose }: { partner: Partner | null; onClose:
city: form.city.trim(),
state: form.state.trim(),
...(form.postcode.trim() ? { postcode: Number(form.postcode) || 0 } : {}),
/*
The district, by id when Nearle already runs it and by NAME when it
does not. The name is what opens it — the server writes the region
rows before the partner, so all 38 are real choices rather than three.
*/
applocationid: chosenDistrict?.applocationid ?? 0,
...(chosenDistrict && chosenDistrict.applocationid === 0
? { district: chosenDistrict.name }
@@ -394,15 +462,6 @@ function PartnerDrawer({ partner, onClose }: { partner: Partner | null; onClose:
? partnersApi.update({ ...body, partnerid: partner.partnerid }).then(() => partner.partnerid)
: partnersApi.create(body).then((result) => result?.partnerid ?? 0);
},
/*
The placement is written after the partner exists, because it needs the
id the create hands back.
Reported separately if it fails, and deliberately not rolled back: the
partner is real either way and re-onboarding them would refuse on the
duplicate contact number. Saying "the partner was created but could not be
placed" is recoverable — the drawer stays open on the same form.
*/
onSuccess: async (partnerid) => {
if (partnerid > 0 && form.tenantid > 0) {
try {
@@ -490,13 +549,6 @@ function PartnerDrawer({ partner, onClose }: { partner: Partner | null; onClose:
onChange={set('registrationno')}
/>
{/* ── District ──────────────────────────────────────────────────────
All 38 of Tamil Nadu's districts, searchable, with only the ones
Nearle services selectable. A partner placed in a district that has
no `app_location` row is a partner whose riders no query returns —
`getriders` filters on that id — so an unserviced district is shown
and refused rather than hidden, because "Erode is not open yet" is
an answer and a missing Erode is not. */}
<VStack gap={1}>
<Text type="label" size="sm" weight="semibold">
District
@@ -510,10 +562,6 @@ function PartnerDrawer({ partner, onClose }: { partner: Partner | null; onClose:
placeholder="Search all 38 districts…"
hasClear
/>
{/* A single-select list, not a cloud of chips: one partner works one
district, so this is a choice with one answer and it should read
like one. Running districts carry a tick, new ones say what will
happen — the difference is operational, not a restriction. */}
<div className="district-list" role="listbox" aria-label="Tamil Nadu districts">
{shown.map((option) => {
const running = isRunning(option);
@@ -528,9 +576,6 @@ function PartnerDrawer({ partner, onClose }: { partner: Partner | null; onClose:
data-chosen={chosen ? 'yes' : 'no'}
onClick={() => {
setDistrict(option.name);
// The merchant is district-scoped, so changing the district
// invalidates it — keeping it would attach a partner to a
// shop in a place they do not work.
setForm((prev) => ({ ...prev, tenantid: 0, locationid: 0 }));
}}
>
@@ -556,10 +601,6 @@ function PartnerDrawer({ partner, onClose }: { partner: Partner | null; onClose:
</Text>
</VStack>
{/* ── Who they deliver for ──────────────────────────────────────────
The merchant, then the branch. Both links are written: the merchant
one is what the assign screen reads to decide whether to offer a
partner tab at all, and the branch one records which outlet. */}
<VStack gap={1}>
<Text type="label" size="sm" weight="semibold">
Delivers for
@@ -569,8 +610,6 @@ function PartnerDrawer({ partner, onClose }: { partner: Partner | null; onClose:
size="sm"
value={form.tenantid ? String(form.tenantid) : ''}
onChange={(value) => {
// A new merchant clears the branch with it — keeping it would
// leave another shop's outlet id attached to this partner.
setForm((prev) => ({ ...prev, tenantid: Number(value) || 0, locationid: 0 }));
}}
options={merchantOptions}