rider partner page

This commit is contained in:
2026-09-09 15:43:23 +05:30
parent 32c612a10d
commit b760c1a078
18 changed files with 1802 additions and 63 deletions

View File

@@ -0,0 +1,612 @@
/**
* 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.
*/
import { useMemo, useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Badge } from '@astryxdesign/core/Badge';
import { Button } from '@astryxdesign/core/Button';
import { Card } from '@astryxdesign/core/Card';
import { HStack } from '@astryxdesign/core/HStack';
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 { errorMessage } from '@/api/client';
import { partnersApi, type NewPartner, type Partner } from '@/api/deliveries';
import { tenantsApi } from '@/api/tenants';
import type { TenantInfo } from '@/api/types';
import { Selector } from '@astryxdesign/core/Selector';
import { districtOptions, isRunning, matchDistrict } from '../tamilNaduDistricts';
import { DataState } from '@/components/DataState';
import { PageHeader } from '@/components/PageHeader';
import { TablePager } from '@/components/TablePager';
import { usePaged } from '@/components/usePaged';
import {
useAllPartners,
useAppRegions,
usePartnerRiderCounts,
useTenantLocations,
useTenants,
} from '@/queries/hooks';
import { queryKeys } from '@/queries/keys';
import { Drawer } from '@/features/store-admin/Drawer';
import { PartnerRidersDrawer } from '../PartnerRidersDrawer';
import { DrawerButton } from '@/features/store-admin/drawerKit';
interface PartnerRow extends Record<string, unknown> {
partnerid: number;
partnername: string;
companyname: string;
region: string;
contact: string;
status: string;
/** How many riders they have. `null` while the count is still being read. */
riders: number | null;
}
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);
/* The fleet size per partner, read alongside the directory. Without it the
Riders button is a door with nothing written on it. */
const riderCounts = usePartnerRiderCounts(partners.data.map((entry) => entry.partnerid));
const regionName = useMemo(() => {
const map = new Map<number, string>();
for (const region of regions.data ?? []) {
map.set(region.applocationid, region.locationname ?? `Region ${region.applocationid}`);
}
return map;
}, [regions.data]);
const rows = useMemo<PartnerRow[]>(
() =>
partners.data.map((partner) => ({
partnerid: partner.partnerid,
partnername: partner.partnername ?? `Partner ${partner.partnerid}`,
companyname: partner.companyname ?? '',
region: regionName.get(partner.applocationid ?? 0) ?? '—',
contact: partner.primarycontact || partner.contactno || '',
status: partner.status || 'Active',
riders: riderCounts.get(partner.partnerid) ?? null,
})),
[partners.data, regionName, riderCounts],
);
const paged = usePaged(rows);
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>
),
},
{
key: 'region',
header: 'Home region',
width: { type: 'proportional', value: 2 },
renderCell: (row) => (
<Text type="body" size="sm" color="secondary">
{row.region}
</Text>
),
},
{
key: 'contact',
header: 'Contact',
width: { type: 'proportional', value: 2 },
renderCell: (row) => (
<Text type="body" size="sm" style={{ fontFamily: 'var(--font-mono)' }}>
{row.contact || '—'}
</Text>
),
},
{
key: 'status',
header: 'Status',
align: 'end',
width: { type: 'pixel', value: 110 },
renderCell: (row) => (
<Badge
variant={row.status.toLowerCase() === 'active' ? 'success' : 'neutral'}
label={row.status}
/>
),
},
{
/* 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 },
renderCell: (row) => (
<HStack gap={0.5} justify="end" align="center" className="fleet-actions">
<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"
icon={<Bike size={13} />}
onClick={() => {
const found = partners.data.find((p) => p.partnerid === row.partnerid);
if (found) setRidersFor(found);
}}
/>
<Button
label="Edit"
variant="ghost"
size="sm"
onClick={() => {
const found = partners.data.find((p) => p.partnerid === row.partnerid);
if (found) setEditing(found);
}}
/>
</HStack>
),
},
];
return (
<VStack gap={3}>
<PageHeader
title="Rider partners"
actions={
<Button
label="Onboard rider partner"
variant="primary"
size="sm"
icon={<Plus size={14} />}
onClick={() => setEditing('new')}
/>
}
/>
<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>
{ridersFor ? (
<PartnerRidersDrawer partner={ridersFor} onClose={() => setRidersFor(null)} />
) : null}
{editing ? (
<PartnerDrawer
partner={editing === 'new' ? null : editing}
onClose={() => setEditing(null)}
/>
) : null}
</VStack>
);
}
/* ── The form ─────────────────────────────────────────────────────────────── */
interface FormState {
partnername: string;
companyname: string;
registrationno: string;
primarycontact: string;
primaryemail: string;
address: string;
suburb: string;
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;
}
const EMPTY: FormState = {
partnername: '',
companyname: '',
registrationno: '',
primarycontact: '',
primaryemail: '',
address: '',
suburb: '',
city: '',
state: '',
postcode: '',
applocationid: 0,
tenantid: 0,
locationid: 0,
};
function PartnerDrawer({ partner, onClose }: { partner: Partner | null; onClose: () => void }) {
const client = useQueryClient();
const regions = useAppRegions();
const isNew = partner === null;
const [form, setForm] = useState<FormState>(() =>
partner
? {
partnername: partner.partnername ?? '',
companyname: partner.companyname ?? '',
registrationno: partner.registrationno ?? '',
primarycontact: partner.primarycontact ?? partner.contactno ?? '',
primaryemail: partner.primaryemail ?? '',
address: partner.address ?? '',
suburb: partner.suburb ?? '',
city: partner.city ?? '',
state: partner.state ?? '',
postcode: '',
applocationid: partner.applocationid ?? 0,
tenantid: 0,
locationid: 0,
}
: EMPTY,
);
const [error, setError] = useState<string | null>(null);
function set<K extends keyof FormState>(key: K) {
return (value: FormState[K]) => {
setError(null);
setForm((prev) => ({ ...prev, [key]: value }));
};
}
/* ── 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]);
const shown = useMemo(
() => districts.filter((option) => matchDistrict(option, districtSearch)),
[districts, districtSearch],
);
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();
const seen = new Map<number, string>();
for (const entry of (merchants.data ?? []) as TenantInfo[]) {
if (!entry.tenantid || seen.has(entry.tenantid)) continue;
const city = (entry.city ?? '').trim().toLowerCase();
if (here && city && city !== here) continue;
seen.set(entry.tenantid, entry.tenantname);
}
return [...seen.entries()].map(([value, label]) => ({ value: String(value), label }));
}, [merchants.data, district]);
const branches = useTenantLocations(form.tenantid || undefined);
const branchOptions = useMemo(
() =>
(branches.data ?? []).map((branch) => ({
value: String(branch.locationid),
label: branch.locationname || `Branch ${branch.locationid}`,
})),
[branches.data],
);
const save = useMutation({
mutationFn: () => {
const body: NewPartner = {
partnername: form.partnername.trim(),
companyname: form.companyname.trim(),
registrationno: form.registrationno.trim(),
primarycontact: form.primarycontact.trim(),
primaryemail: form.primaryemail.trim(),
address: form.address.trim(),
suburb: form.suburb.trim(),
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 }
: {}),
};
return partner
? 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 {
await tenantsApi.assignPartner(form.tenantid, partnerid);
if (form.locationid > 0) {
await tenantsApi.updateBranch({ locationid: form.locationid, partnerid });
}
} catch (cause) {
await client.invalidateQueries({ queryKey: queryKeys.partners.all });
setError(
`${form.partnername.trim()} was saved, but could not be assigned to that branch: ${errorMessage(cause)}`,
);
return;
}
}
await client.invalidateQueries({ queryKey: queryKeys.partners.all });
await client.invalidateQueries({ queryKey: queryKeys.tenants.all });
onClose();
},
onError: (cause) => setError(errorMessage(cause)),
});
const isComplete =
form.partnername.trim() !== '' && form.primarycontact.trim() !== '' && form.applocationid > 0;
return (
<Drawer
title={isNew ? 'Onboard a rider partner' : form.partnername || 'Rider partner'}
subtitle={isNew ? 'The company that supplies riders' : `Partner ${partner?.partnerid}`}
width={520}
onClose={onClose}
isFooterSpread
footer={
<>
<DrawerButton label="Cancel" variant="ghost" onClick={onClose} />
<DrawerButton
label={save.isPending ? 'Saving…' : isNew ? 'Onboard rider partner' : 'Save changes'}
variant="primary"
icon={<Truck size={15} />}
isDisabled={!isComplete || save.isPending}
onClick={() => save.mutate()}
/>
</>
}
>
<VStack gap={2}>
{error ? (
<Text type="body" size="sm" style={{ color: 'var(--color-error, #d64545)' }}>
{error}
</Text>
) : null}
<TextInput
label="Partner name"
size="sm"
value={form.partnername}
onChange={set('partnername')}
placeholder="e.g. Xpress-Cbe-Main"
description="How the partner is named on rider records and in the assign picker."
/>
<TextInput
label="Registered company"
size="sm"
value={form.companyname}
onChange={set('companyname')}
/>
<TextInput
label="Primary contact"
size="sm"
value={form.primarycontact}
onChange={set('primarycontact')}
placeholder="9876543210"
description="One partner per number — the server refuses a second."
/>
<TextInput
label="Email"
size="sm"
value={form.primaryemail}
onChange={set('primaryemail')}
/>
<TextInput
label="Registration number"
size="sm"
value={form.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}>
<Text type="label" size="sm" weight="semibold">
District
</Text>
<TextInput
label="Search districts"
isLabelHidden
size="sm"
value={districtSearch}
onChange={setDistrictSearch}
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);
const chosen = district === option.name;
return (
<button
key={option.name}
type="button"
role="option"
aria-selected={chosen}
className="district-row"
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 }));
}}
>
<span className="district-row-name">{option.name}</span>
<span className="district-row-state" data-running={running ? 'yes' : 'no'}>
{running ? 'Running' : 'New district'}
</span>
</button>
);
})}
{shown.length === 0 ? (
<Text type="body" size="sm" color="secondary" style={{ padding: '10px 12px' }}>
No district matches “{districtSearch}”.
</Text>
) : null}
</div>
<Text type="body" size="xsm" color="secondary">
{chosenDistrict
? isRunning(chosenDistrict)
? `Riders are listed against ${chosenDistrict.name}.`
: `${chosenDistrict.name} will be opened when this partner is saved.`
: 'One district per partner. Choosing one Nearle does not run yet opens it.'}
</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
</Text>
<Selector
label="Merchant"
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}
placeholder={merchants.isLoading ? 'Loading merchants…' : 'Choose a merchant'}
/>
<Selector
label="Branch"
size="sm"
value={form.locationid ? String(form.locationid) : ''}
onChange={(value) => set('locationid')(Number(value) || 0)}
options={branchOptions}
isDisabled={!form.tenantid}
placeholder={
!form.tenantid
? 'Choose a merchant first'
: branches.isLoading
? 'Loading branches…'
: 'Choose the branch they cover'
}
/>
<Text type="body" size="xsm" color="secondary">
Optional. Set it and this partner’s riders become an option on that shop’s assign
screen, beside any riders it hired itself.
</Text>
</VStack>
<TextInput label="Address" size="sm" value={form.address} onChange={set('address')} />
<HStack gap={1}>
<TextInput label="Area" size="sm" value={form.suburb} onChange={set('suburb')} />
<TextInput label="City" size="sm" value={form.city} onChange={set('city')} />
</HStack>
<HStack gap={1}>
<TextInput label="State" size="sm" value={form.state} onChange={set('state')} />
<TextInput label="Postcode" size="sm" value={form.postcode} onChange={set('postcode')} />
</HStack>
</VStack>
</Drawer>
);
}