Files
daily_console_web/src/features/nearle-admin/pages/PartnersPage.tsx
2026-09-09 15:43:23 +05:30

613 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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>
);
}