Files
daily_console_web/src/features/nearle-admin/PartnerAssignDrawer.tsx
2026-09-17 17:57:21 +05:30

205 lines
6.7 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.
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>
);
}