205 lines
6.7 KiB
TypeScript
205 lines
6.7 KiB
TypeScript
import { useMemo, useState } from 'react';
|
||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||
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 { Alert, 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 ? <Alert>{error}</Alert> : 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>
|
||
);
|
||
}
|