rider partner page
This commit is contained in:
209
src/features/nearle-admin/PartnerAssignDrawer.tsx
Normal file
209
src/features/nearle-admin/PartnerAssignDrawer.tsx
Normal file
@@ -0,0 +1,209 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { Bike, Check, Truck } from 'lucide-react';
|
||||
import { errorMessage } from '@/api/client';
|
||||
import { tenantsApi } from '@/api/tenants';
|
||||
import { useAllPartners, useAppRegions } from '@/queries/hooks';
|
||||
import { queryKeys } from '@/queries/keys';
|
||||
import { Drawer } from '@/features/store-admin/Drawer';
|
||||
import { DrawerButton, DrawerCard, Note, Row, Section } from '@/features/store-admin/drawerKit';
|
||||
|
||||
/**
|
||||
* Which delivery partner supplies a merchant's riders.
|
||||
*
|
||||
* ── Why this is the platform's decision ─────────────────────────────────────
|
||||
*
|
||||
* `partnerid` is kept out of the merchant-editable allowlist on the server, so
|
||||
* this cannot be done from the shop's own profile — a merchant who could set it
|
||||
* would move themselves under another partner's riders and billing. It is
|
||||
* changed here, by whoever is looking at that merchant's record.
|
||||
*
|
||||
* ── Why "own riders" is an option and not an absence ────────────────────────
|
||||
*
|
||||
* Sending `partnerid: 0` is a real instruction: it means the shop delivers with
|
||||
* riders it hired itself. The server reads a zero as sent rather than as a
|
||||
* missing field, which is exactly why the endpoint is separate — everywhere
|
||||
* else in the tenant API a zero means "not supplied", and there it would
|
||||
* silently unassign somebody.
|
||||
*
|
||||
* One partner per merchant, which is what `tenants.partnerid` allows and what
|
||||
* the assign picker later branches on. A partner serving several merchants is
|
||||
* the ordinary case in the other direction: 44 supplies 48 shops.
|
||||
*/
|
||||
export function PartnerAssignDrawer({
|
||||
tenantid,
|
||||
tenantname,
|
||||
currentPartnerId,
|
||||
onClose,
|
||||
}: {
|
||||
tenantid: number;
|
||||
tenantname: string;
|
||||
currentPartnerId: number;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const client = useQueryClient();
|
||||
const partners = useAllPartners();
|
||||
const regions = useAppRegions();
|
||||
const [chosen, setChosen] = useState<number>(currentPartnerId);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
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 save = useMutation({
|
||||
mutationFn: () => tenantsApi.assignPartner(tenantid, chosen),
|
||||
onSuccess: async () => {
|
||||
await client.invalidateQueries({ queryKey: queryKeys.tenants.all });
|
||||
onClose();
|
||||
},
|
||||
onError: (cause) => setError(errorMessage(cause)),
|
||||
});
|
||||
|
||||
const current = partners.data.find((p) => p.partnerid === currentPartnerId);
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title="Delivery partner"
|
||||
subtitle={tenantname}
|
||||
width={460}
|
||||
onClose={onClose}
|
||||
isFooterSpread
|
||||
footer={
|
||||
<>
|
||||
<DrawerButton label="Cancel" variant="ghost" onClick={onClose} />
|
||||
<DrawerButton
|
||||
label={save.isPending ? 'Saving…' : 'Save'}
|
||||
variant="primary"
|
||||
icon={<Truck size={15} />}
|
||||
isDisabled={save.isPending || chosen === currentPartnerId}
|
||||
onClick={() => save.mutate()}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<VStack gap={2}>
|
||||
{error ? (
|
||||
<Text type="body" size="sm" style={{ color: 'var(--color-error, #d64545)' }}>
|
||||
{error}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
<Note icon={<Bike size={15} />}>
|
||||
A partner supplies riders to this shop. With one set, the assign screen offers the
|
||||
partner’s riders alongside any the shop hired itself; without one, only its own.
|
||||
</Note>
|
||||
|
||||
<Section title="Currently">
|
||||
<DrawerCard>
|
||||
<Row
|
||||
label="Partner"
|
||||
value={current?.partnername ?? (currentPartnerId > 0 ? `Partner ${currentPartnerId}` : 'Own riders only')}
|
||||
/>
|
||||
</DrawerCard>
|
||||
</Section>
|
||||
|
||||
<Section title="Change to">
|
||||
<DrawerCard>
|
||||
{/* "Own riders" first and always present. It is not the empty state
|
||||
— a shop that hires its own riders is a real arrangement, and
|
||||
the option has to be as reachable as any partner. */}
|
||||
<Choice
|
||||
label="Own riders only"
|
||||
detail="This shop delivers with riders it hired itself."
|
||||
isChosen={chosen === 0}
|
||||
onChoose={() => setChosen(0)}
|
||||
/>
|
||||
{partners.isLoading ? (
|
||||
<Row label="Partners" value="Reading…" />
|
||||
) : (
|
||||
partners.data.map((partner) => (
|
||||
<Choice
|
||||
key={partner.partnerid}
|
||||
label={partner.partnername ?? `Partner ${partner.partnerid}`}
|
||||
detail={
|
||||
[
|
||||
regionName.get(partner.applocationid ?? 0),
|
||||
partner.companyname,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ') || 'No region recorded'
|
||||
}
|
||||
isChosen={chosen === partner.partnerid}
|
||||
onChoose={() => setChosen(partner.partnerid)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</DrawerCard>
|
||||
</Section>
|
||||
</VStack>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
function Choice({
|
||||
label,
|
||||
detail,
|
||||
isChosen,
|
||||
onChoose,
|
||||
}: {
|
||||
label: string;
|
||||
detail: string;
|
||||
isChosen: boolean;
|
||||
onChoose: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onChoose}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
width: '100%',
|
||||
padding: '12px 14px',
|
||||
border: 0,
|
||||
borderTop: '1px solid var(--color-line)',
|
||||
background: isChosen ? 'var(--color-brand-tint)' : 'transparent',
|
||||
cursor: 'pointer',
|
||||
textAlign: 'left',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
style={{
|
||||
width: 18,
|
||||
height: 18,
|
||||
flex: 'none',
|
||||
borderRadius: 999,
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
border: isChosen ? 0 : '1.5px solid var(--color-line)',
|
||||
background: isChosen ? 'var(--color-brand)' : 'transparent',
|
||||
color: '#fff',
|
||||
}}
|
||||
>
|
||||
{isChosen ? <Check size={11} strokeWidth={3} /> : null}
|
||||
</span>
|
||||
<span style={{ display: 'flex', flexDirection: 'column', gap: 2, minWidth: 0 }}>
|
||||
<span
|
||||
style={{
|
||||
font: '500 13.5px/1.4 var(--font-sans)',
|
||||
color: isChosen ? 'var(--color-brand)' : 'var(--color-ink-1)',
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
<span style={{ font: '400 12px/1.4 var(--font-sans)', color: 'var(--color-ink-3)' }}>
|
||||
{detail}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user