613 lines
24 KiB
TypeScript
613 lines
24 KiB
TypeScript
/**
|
||
* 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>
|
||
);
|
||
}
|