This commit is contained in:
2026-09-25 15:21:02 +05:30
parent 72f3bb0961
commit d169d24934
12 changed files with 516 additions and 28 deletions

View File

@@ -319,6 +319,7 @@ export interface RiderShift {
*/
export interface NewRiderShift {
applocationid: number;
shiftname?: string;
starttime: string;
endtime: string;
basefare?: number;

View File

@@ -12,6 +12,7 @@ import type {
DeliveryRow,
DeliverySummary,
LocationOrderSummary,
OrderItem,
OrderRow,
OrderSummary,
PosLocationHealth,
@@ -163,6 +164,32 @@ export const insightsApi = {
...range,
}),
/**
* What is actually IN an order.
*
* The only read that carries line items. Every list endpoint returns an
* order's totals and never its contents, which is why the detail sheet could
* say an order was worth ₹840 and not what the ₹840 bought.
*
* The envelope rather than `api.list`, because the authoritative total lives
* outside `details`: `OrderDetail.Orderamount` is `json:"-"` on the server, so
* `pricedetails.orderamount` is the only place it appears. Summing the lines
* would be recomputing a figure Fiesta has already worked out, and the two
* would disagree the first time a discount rounded differently.
*/
orderItems: async (orderheaderid: number) => {
const envelope = await api.envelope<OrderItem[]>(`${WEB}/orders/getorderdetails`, {
params: { orderheaderid },
});
return {
// `details: null` for an order with no lines is as common here as `[]`;
// see the note on `api.list`.
items: envelope.details ?? [],
amount: envelope.pricedetails?.orderamount ?? 0,
tax: envelope.pricedetails?.totaltaxamount ?? 0,
};
},
deliverySummary: (tenantid: number, range: DateRange = {}) =>
api.get<DeliverySummary>(`${WEB}/deliveries/deliverysummary`, { tenantid, ...range }),

View File

@@ -192,6 +192,24 @@ export const posUsersApi = {
locationid,
})
.then((page) => (Array.isArray(page?.shifts) ? page.shifts : [])),
/**
* Open a shift window at one branch.
*
* The endpoint has existed since till staff were built; nothing in the console
* called it. So `getstaffshifts` answered `{"shifts": []}` at every branch —
* the comment on StoreStaffPage says exactly that — and the picker on this
* drawer offered "Any shift" and nothing else, for everyone, permanently.
*
* `weekdays` is a seven-character mask starting Monday; empty means every day.
* The server rejects anything that is not seven 0/1 characters, so it is sent
* as the mask rather than as a list the console would have to encode twice.
*/
createShift: (
tenantid: number,
locationid: number,
shift: { name: string; start_time: string; end_time: string; weekdays?: string },
) => api.post<StaffShift>(`${WEB}/tenants/createstaffshift`, { tenantid, locationid, ...shift }),
};
/**

View File

@@ -44,6 +44,52 @@ export interface FiestaEnvelope<T> {
token?: string;
/** Unix seconds. The tab closing normally ends the session well before this. */
tokenexpiresat?: number;
/**
* Order totals, from `orders/getorderdetails` only.
*
* Beside `details` rather than inside it because the line items are a list
* and this is one figure about the order as a whole. `OrderDetail.Orderamount`
* is tagged `json:"-"` on the server, so the authoritative total exists HERE
* and nowhere else in the response — summing the lines is an approximation of
* a number Fiesta has already worked out.
*/
pricedetails?: { orderamount?: number; totaltaxamount?: number };
}
/* ────────────────────────────────────────────────────────────────────────────
Order line items — models/order.go:OrderDetail
──────────────────────────────────────────────────────────────────────────── */
/**
* One product on an order.
*
* Returned by `orders/getorderdetails`, which is the ONLY read that carries
* them — the list endpoints give an order's totals and never what is in it.
* That is why every screen showing an order has, until now, been able to say
* what it cost but not what it was.
*/
export interface OrderItem {
orderdetailid: number;
orderheaderid: number;
productid: number;
productname: string;
productdescription?: string;
/** What was ordered. A float because some products sell by weight. */
orderqty: number;
/** What the shop could actually supply — less than `orderqty` is a short fill. */
supplyqty?: number;
price: number;
unitname?: string;
taxamount?: number;
discountamount?: number;
/** The line total as Fiesta computed it: quantity, tax and discount applied. */
productsumprice?: number;
itemstatus?: string;
/**
* Always empty today. The column is `gorm:"-"` on the server, so it is
* serialised and never populated — do not build a thumbnail on it.
*/
productimage?: string;
}
/* ────────────────────────────────────────────────────────────────────────────

View File

@@ -1,11 +1,13 @@
import type { ReactNode } from 'react';
import { ArrowDown, Bike, Check, MapPin, Phone, X } from 'lucide-react';
import type { DeliveryRow, OrderRow } from '@/api/types';
import type { DeliveryRow, OrderItem, OrderRow } from '@/api/types';
import { orderStage, type Stage } from './orderProgress';
import { useDeliveryMoves } from './DeliveryProgress';
import { Drawer } from './Drawer';
import { Badge, DrawerButton, DrawerCard, Metric, Metrics, Note, Row, Section } from './drawerKit';
import { branchLabel, moneyExact } from './format';
import { lineTotal, qtyLabel, summarise } from './orderItems';
import { useOrderItems } from '@/queries/hooks';
import {
DELIVERY_STATUS,
ORDER_STATUS,
@@ -135,6 +137,30 @@ function Sheet({
const value = job ? (job.deliveryamt ?? 0) : orderValue(row);
const cancelled = (status ?? '').toLowerCase().includes('cancel');
/*
* What is in the order, read once here and used twice.
*
* The three figures at the top of this sheet — value, cash to collect, items
* — came only from the row the list handed over, and the list does not carry
* them on every order: order 1151-1 opened with "Value —", "Cash to collect
* —", "Items —" while the section at the bottom of the same sheet correctly
* reported one item. Two blocks describing the same order, disagreeing,
* because one of them had asked Fiesta and the other had not.
*
* So the detail read fills the gaps. The row wins when it has a figure — it
* is the number the table behind this sheet is showing, and the two must not
* differ — and this answers only where the row was silent.
*
* One fetch: the same query key, passed down rather than called again.
*/
const detail = useOrderItems(row.orderheaderid);
const items = detail.data?.items ?? [];
const detailAmount = detail.data?.amount ?? 0;
// Units, not lines, because this metric sits beside a money figure and reads
// as "how much is in it". The Items section below names both.
const itemCount =
(job ? job.itemcount : orderQuantity(row)) || Math.round(summarise(items).units);
return (
<Drawer
title={title}
@@ -152,7 +178,13 @@ function Sheet({
Three equal columns, the shape the spec asks for and the one that
answers "what is this worth, who pays what, how much of it". */}
<Metrics>
<Metric label="Value" value={value > 0 ? moneyExact(value) : '—'} />
{/* The row's figure where it has one, Fiesta's where it does not. An
order the list reported as worth nothing is usually an order the
list was not told about, not a free one. */}
<Metric
label="Value"
value={value > 0 ? moneyExact(value) : detailAmount > 0 ? moneyExact(detailAmount) : '—'}
/>
{job ? (
<Metric
label="Rider charge"
@@ -166,11 +198,10 @@ function Sheet({
isSmall
/>
)}
<Metric
label="Items"
value={job ? String(job.itemcount ?? '—') : String(orderQuantity(row) || '—')}
isSmall
/>
{/* Counted from the lines when the row carries no count. `itemcount`
and `quantity` are both routinely absent on the list read, which is
what made this a dash on an order that plainly had something in it. */}
<Metric label="Items" value={itemCount > 0 ? String(itemCount) : '—'} isSmall />
</Metrics>
<Section title="Order journey">
@@ -262,10 +293,7 @@ function Sheet({
</Section>
) : null}
<Note>
Line items are not in the list read — Fiesta returns them from the order-detail endpoint,
which this sheet does not call yet.
</Note>
<OrderItems items={items} isLoading={detail.isLoading} isError={detail.isError} />
</Drawer>
);
}
@@ -447,3 +475,95 @@ const km = (value: string | undefined): string => {
const n = Number(value);
return Number.isFinite(n) && n > 0 ? `${n.toFixed(1)} km` : '—';
};
/* ── What is actually in the order ───────────────────────────────────────── */
/**
* The products on the order, line by line.
*
* This sheet has always been able to say what an order was WORTH and never what
* it was. The note that used to sit here said as much: Fiesta returns the
* contents from `orders/getorderdetails` and nothing called it. So an operator
* chasing a stalled delivery could see ₹840 and had to open another system to
* find out whether that was rice or ice cream — which decides whether it can
* wait an hour.
*
* Handed its data rather than fetching its own. The drawer above needs the same
* read to fill the three figures in its header, and two components asking
* separately is how the header came to say "Items —" on an order this section
* was, at that moment, listing.
*/
function OrderItems({
items,
isLoading,
isError,
}: {
items: readonly OrderItem[];
isLoading: boolean;
isError: boolean;
}) {
if (isLoading) {
return (
<Section title="Items">
<Note>Reading what is in this order…</Note>
</Section>
);
}
if (isError) {
// Not a blank section. The rest of the sheet is sound and this one read
// failed; saying so beats an empty heading that reads like an empty order.
return (
<Section title="Items">
<Note>Could not read the items on this order.</Note>
</Section>
);
}
if (items.length === 0) {
return (
<Section title="Items">
<DrawerCard tone="subtle">
<Row label="Contents" value="Fiesta returned no line items for this order." isStacked />
</DrawerCard>
</Section>
);
}
const { lines, units, short } = summarise(items);
return (
<Section
title={
// Both numbers when they differ, because they answer different
// questions: four products, twelve units. One labelled as the other is
// how a picker packs the wrong trolley.
units !== lines ? `Items · ${lines} products · ${qtyLabel(units)} units` : `Items · ${lines}`
}
>
{short > 0 ? (
<Note>
{short === 1 ? 'One line was' : `${short} lines were`} short-supplied — the shop could not
give the full quantity ordered.
</Note>
) : null}
<DrawerCard>
{items.map((item) => (
<Row
key={item.orderdetailid}
label={item.productname?.trim() || `Product ${item.productid}`}
value={
<span style={{ display: 'inline-flex', alignItems: 'baseline', gap: 10 }}>
{/* Quantity first: a picker reads this column, not the price. */}
<span style={{ color: 'var(--color-ink-2)' }}>
{qtyLabel(item.orderqty, item.unitname)}
</span>
<span style={{ fontWeight: 600 }}>{moneyExact(lineTotal(item))}</span>
</span>
}
/>
))}
</DrawerCard>
</Section>
);
}

View File

@@ -1,6 +1,6 @@
import { useState, type FormEvent } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Copy, MapPin } from 'lucide-react';
import { Clock, Copy, MapPin } from 'lucide-react';
import { errorMessage } from '@/api/client';
import { normaliseMobile, posUsersApi, staffApi, weekdayLabel } from '@/api/people';
import type { PosRole, PosUser, StaffInfo, StaffShift, TenantLocation } from '@/api/types';
@@ -333,6 +333,40 @@ export function TillDrawer({
/** The credential the backend hands back on create. Shown once, never re-read. */
const [issued, setIssued] = useState<string | null>(null);
/*
* Opening a shift window, from the form that needs one.
*
* `createstaffshift` has existed on the server since till staff were built and
* nothing in the console ever called it — so `getstaffshifts` answered
* `{"shifts": []}` at every branch, and this drawer's picker offered "Any
* shift" and nothing else, for every cashier, permanently. The same gap the
* riders had: a read wired up, a write that only the database could reach.
*
* Here rather than on a settings screen because this is where it is missed.
*/
const [newShift, setNewShift] = useState({ name: '', start_time: '09:00', end_time: '17:00' });
const setShiftField = (field: keyof typeof newShift) => (value: string) =>
setNewShift((prev) => ({ ...prev, [field]: value }));
const addShift = useMutation({
mutationFn: () =>
posUsersApi.createShift(tenantid, locationid, {
name: newShift.name.trim(),
start_time: newShift.start_time.trim(),
end_time: newShift.end_time.trim(),
}),
onSuccess: async (shift) => {
setProblem(null);
await queryClient.invalidateQueries({ queryKey: queryKeys.people.shifts(tenantid, locationid) });
// Selected straight away — somebody who just typed these hours meant to
// put this person on them.
if (shift?.staff_shift_id) setShiftId(String(shift.staff_shift_id));
},
onError: (cause) => setProblem(errorMessage(cause)),
});
const hasNoShifts = shifts.length === 0;
const save = useMutation({
mutationFn: async () => {
const status = isActive ? 'Active' : 'Inactive';
@@ -517,6 +551,44 @@ export function TillDrawer({
]}
/>
</Field>
{/* The way out of the dead end. Only when the branch genuinely has none
— a shift creator on a screen that already has shifts is how you end
up with four overlapping windows nobody can tell apart. */}
{hasNoShifts ? (
<>
<Note>
No shift windows exist at {branchName || 'this branch'} yet, so there is nothing to
put anyone on. Name one and give its hours — every till account here can then use it.
</Note>
<Field
label="Name"
isRequired
description="What people call it — Morning, Evening, Weekend cover."
>
<TextField value={newShift.name} onChange={setShiftField('name')} />
</Field>
<FieldRow>
<Field label="Starts" isRequired>
<TextField value={newShift.start_time} onChange={setShiftField('start_time')} />
</Field>
<Field label="Ends" isRequired>
<TextField value={newShift.end_time} onChange={setShiftField('end_time')} />
</Field>
</FieldRow>
<DrawerButton
label={addShift.isPending ? 'Adding…' : 'Add this shift'}
icon={<Clock size={15} />}
isDisabled={
addShift.isPending ||
newShift.name.trim() === '' ||
newShift.start_time.trim() === '' ||
newShift.end_time.trim() === ''
}
onClick={() => addShift.mutate()}
/>
</>
) : null}
</Section>
<Section title="Access">

View File

@@ -125,7 +125,7 @@ export function RiderDrawer({ row, owner, branch, onClose }: RiderDrawerProps) {
* wall is hit. A person hiring their first rider in a new city should not
* have to find out that shifts are a separate concept somewhere else.
*/
const [newShift, setNewShift] = useState({ starttime: '09:00', endtime: '17:00' });
const [newShift, setNewShift] = useState({ shiftname: '', starttime: '09:00', endtime: '17:00' });
const setShiftField = (key: keyof typeof newShift) => (value: string) =>
setNewShift((prev) => ({ ...prev, [key]: value }));
@@ -133,6 +133,7 @@ export function RiderDrawer({ row, owner, branch, onClose }: RiderDrawerProps) {
mutationFn: () =>
ridersApi.createShift({
applocationid,
shiftname: newShift.shiftname.trim(),
starttime: newShift.starttime.trim(),
endtime: newShift.endtime.trim(),
}),
@@ -308,11 +309,18 @@ export function RiderDrawer({ row, owner, branch, onClose }: RiderDrawerProps) {
none — a shift creator on a screen that already has shifts is a way
to end up with four overlapping windows nobody can tell apart. */}
{hasNoShifts && applocationid > 0 ? (
<DrawerCard tone="subtle">
<>
<Note>
No shift windows exist in this region yet, and a rider cannot be hired without
one. Set the hours they work — every rider in this region can then be put on it.
</Note>
<Field
label="Name"
isRequired
description="What people call it — Morning, Evening, Weekend cover."
>
<TextField value={newShift.shiftname} onChange={setShiftField('shiftname')} />
</Field>
<FieldRow>
<Field label="Starts" isRequired>
<TextField value={newShift.starttime} onChange={setShiftField('starttime')} />
@@ -326,12 +334,13 @@ export function RiderDrawer({ row, owner, branch, onClose }: RiderDrawerProps) {
icon={<Clock size={15} />}
isDisabled={
addShift.isPending ||
newShift.shiftname.trim() === '' ||
newShift.starttime.trim() === '' ||
newShift.endtime.trim() === ''
}
onClick={() => addShift.mutate()}
/>
</DrawerCard>
</>
) : null}
{/* A merchant's rider works out of one of that merchant's branches.
@@ -388,17 +397,21 @@ export function RiderDrawer({ row, owner, branch, onClose }: RiderDrawerProps) {
</Section>
{!isNew ? (
<Section title="Status">
<Field label="Status">
<SelectField
value={form.status}
onChange={set('status')}
options={[
{ value: 'Active', label: 'Active' },
{ value: 'InActive', label: 'Inactive — cannot be given work' },
]}
<Section title="Access">
<ChoiceGroup>
<Choice
label="Active"
note="Can be given work"
isSelected={form.status.toLowerCase() !== 'inactive'}
onSelect={() => set('status')('Active')}
/>
</Field>
<Choice
label="Inactive"
note="Cannot be given work"
isSelected={form.status.toLowerCase() === 'inactive'}
onSelect={() => set('status')('InActive')}
/>
</ChoiceGroup>
</Section>
) : null}

View File

@@ -0,0 +1,90 @@
import { strict as assert } from 'node:assert';
import { test } from 'node:test';
import { lineTotal, qtyLabel, summarise } from './orderItems';
import type { OrderItem } from '@/api/types';
/*
What an order contains, as opposed to what it cost.
Every list read in this product returns an order's totals and none of them
return its contents, so the detail sheet could say ₹840 and not whether that was
rice or ice cream — which is what decides whether a stalled delivery can wait an
hour. These cover the arithmetic behind the new Items section.
*/
const item = (over: Partial<OrderItem> = {}): OrderItem => ({
orderdetailid: 1,
orderheaderid: 900,
productid: 10,
productname: 'Sona Masoori rice 25kg',
orderqty: 1,
price: 100,
...over,
});
test('lines and units are different numbers, and both are reported', () => {
// "How many products" has two honest answers. Showing one and labelling it as
// the other is how a picker packs the wrong trolley.
const summary = summarise([
item({ orderdetailid: 1, orderqty: 2 }),
item({ orderdetailid: 2, orderqty: 10 }),
]);
assert.equal(summary.lines, 2);
assert.equal(summary.units, 12);
});
test('an empty order summarises to zero rather than throwing', () => {
assert.deepEqual(summarise([]), { lines: 0, units: 0, short: 0 });
});
test('a missing supplyqty is not a short fill', () => {
// The old console read the absent field as 0 and reported every order short.
// Absent means "not sent", which is not the same as "could not supply".
assert.equal(summarise([item({ orderqty: 5 })]).short, 0);
assert.equal(summarise([item({ orderqty: 5, supplyqty: 5 })]).short, 0);
assert.equal(summarise([item({ orderqty: 5, supplyqty: 2 })]).short, 1);
});
test('quantity reads the way a person writes it', () => {
// A column of "2.00" reads like a price. Weights keep their decimals.
assert.equal(qtyLabel(2), '2');
assert.equal(qtyLabel(1.5), '1.5');
assert.equal(qtyLabel(1.5, 'kg'), '1.5 kg');
assert.equal(qtyLabel(2, 'pcs'), '2 pcs');
});
test('a quantity that is not a number shows a dash, not NaN', () => {
assert.equal(qtyLabel(Number.NaN), '—');
assert.equal(qtyLabel(Number.POSITIVE_INFINITY), '—');
});
test("a line's total is Fiesta's own figure where there is one", () => {
// `productsumprice` has tax and discount applied. Recomputing price × qty
// would disagree with the order total the first time a discount rounded
// differently, and the sheet would contradict itself.
assert.equal(lineTotal(item({ price: 100, orderqty: 3, productsumprice: 318.6 })), 318.6);
});
test('a line with no computed total falls back rather than showing blank', () => {
// Rows predating that column. Price × quantity is the best available answer.
assert.equal(lineTotal(item({ price: 100, orderqty: 3 })), 300);
assert.equal(lineTotal(item({ price: 100, orderqty: 3, productsumprice: 0 })), 300);
});
test('a line with nothing to go on is zero, not NaN', () => {
// A NaN renders as "₹NaN" on the row, which reads as a broken order rather
// than a thin one.
const total = lineTotal({ orderdetailid: 2, orderheaderid: 900, productid: 1, productname: 'x' } as OrderItem);
assert.equal(total, 0);
});
test('a weighed order sums to a fractional unit count', () => {
// 1.5kg of rice plus 2 packets is 3.5 units across 2 lines. Rounding this to
// an integer would report 3 or 4 units, neither of which was ordered.
const summary = summarise([item({ orderdetailid: 1, orderqty: 1.5 }), item({ orderdetailid: 2, orderqty: 2 })]);
assert.equal(summary.lines, 2);
assert.equal(summary.units, 3.5);
assert.equal(qtyLabel(summary.units), '3.5');
});

View File

@@ -0,0 +1,77 @@
import type { OrderItem } from '@/api/types';
/**
* What an order's contents add up to.
*
* Kept out of the drawer so it can be tested without rendering one, and because
* three screens now ask the same question — the Sales order sheet, the Dispatch
* stop sheet, and the customer sheet on the store workspace.
*
* ── Quantity is not a count of rows ─────────────────────────────────────────
*
* "How many products" has two honest answers and they are different numbers:
* four LINES on the order, twelve UNITS across them. Showing one and labelling
* it as the other is how a picker packs the wrong trolley, so both are returned
* and the drawer names each.
*
* `orderqty` is a float because some products sell by weight — 1.5kg of rice is
* one line and one and a half units. Summing it gives a number that must be
* rendered with `qtyLabel`, never with `toFixed(0)`.
*/
export interface ItemSummary {
/** Distinct products on the order. */
lines: number;
/** Units across every line, which is not the same number. */
units: number;
/** Lines the shop could not fully supply. */
short: number;
}
export function summarise(items: readonly OrderItem[]): ItemSummary {
let units = 0;
let short = 0;
for (const item of items) {
units += item.orderqty || 0;
// `supplyqty` is what the shop could actually give. Undefined means the
// field was not sent, which is not the same as a short fill and must not be
// counted as one — the old console read it as 0 and reported every order
// short.
if (typeof item.supplyqty === 'number' && item.supplyqty < (item.orderqty || 0)) {
short += 1;
}
}
return { lines: items.length, units, short };
}
/**
* A quantity as a person writes it.
*
* Whole numbers have no decimal tail — "2", not "2.00" — because most orders
* are whole units and a column of `2.00` reads like a price. Weights keep up to
* two decimals and lose the trailing zeros, so 1.50 shows as 1.5.
*/
export function qtyLabel(qty: number, unit?: string): string {
if (!Number.isFinite(qty)) return '—';
const rounded = Math.round(qty * 100) / 100;
const text = Number.isInteger(rounded) ? String(rounded) : String(rounded);
return unit ? `${text} ${unit}` : text;
}
/**
* What one line cost.
*
* `productsumprice` is Fiesta's own figure with tax and discount applied, and it
* is preferred wherever it is present — recomputing it here would disagree with
* the order total the moment a discount rounded differently. The fallback is
* for rows that predate that column, where price × quantity is the best
* available answer rather than a blank.
*/
export function lineTotal(item: OrderItem): number {
if (typeof item.productsumprice === 'number' && item.productsumprice > 0) {
return item.productsumprice;
}
return (item.price || 0) * (item.orderqty || 0);
}

View File

@@ -39,10 +39,15 @@ import { usePaged } from '@/components/usePaged';
* shift_name / shift_start / shift_end 0%
*
* The old table spent a sixth of its width on a Shift column. Those fields
* exist on the model with `omitempty` and are filled for nobody —
* `getstaffshifts` answers `{"shifts": []}` at every branch — so the column
* exist on the model with `omitempty` and were filled for nobody —
* `getstaffshifts` answered `{"shifts": []}` at every branch — so the column
* read "Any" on every row of every shop. A shift now rides under the name,
* where it appears if one is ever set and takes no space while none is.
* where it appears if one is set and takes no space while none is.
*
* The empty answer was not a data problem: `createstaffshift` had existed since
* till staff were built and nothing in the console called it, so no branch
* could ever have a shift. The till drawer now offers to open one when a branch
* has none, which is why this column can finally show something.
*
* `contactno` got the same treatment for the same reason at 50%.
*

View File

@@ -757,3 +757,19 @@ export function useRiderLive(userids: readonly number[], isEnabled = true) {
isFetching: results.some((result) => result.isFetching),
};
}
/**
* What is in one order — the line items, and the total Fiesta computed.
*
* Enabled only when a drawer is actually open on an order, because this is a
* request per order and the list behind it can hold hundreds. A delivery row
* carries the same `orderheaderid`, so the same hook serves both kinds of sheet.
*/
export function useOrderItems(orderheaderid: number | undefined) {
return useQuery({
queryKey: queryKeys.insights.orderItems(orderheaderid ?? 0),
queryFn: () => insightsApi.orderItems(orderheaderid as number),
enabled: Boolean(orderheaderid),
...stable,
});
}

View File

@@ -90,6 +90,9 @@ export const queryKeys = {
/** The directory — everyone, working today or not. */
riderRoster: (tenantid: number) =>
[...queryKeys.insights.all, 'rider-roster', tenantid] as const,
/** What is in one order. Keyed by the order, never by the list it came from. */
orderItems: (orderheaderid: number) =>
[...queryKeys.insights.all, 'order-items', orderheaderid] as const,
riderShifts: (applocationid: number) =>
[...queryKeys.insights.all, 'rider-shifts', applocationid] as const,
partners: (applocationid: number) =>