ui fix
This commit is contained in:
@@ -319,6 +319,7 @@ export interface RiderShift {
|
||||
*/
|
||||
export interface NewRiderShift {
|
||||
applocationid: number;
|
||||
shiftname?: string;
|
||||
starttime: string;
|
||||
endtime: string;
|
||||
basefare?: number;
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
|
||||
@@ -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 }),
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
90
src/features/store-admin/orderItems.test.ts
Normal file
90
src/features/store-admin/orderItems.test.ts
Normal 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');
|
||||
});
|
||||
77
src/features/store-admin/orderItems.ts
Normal file
77
src/features/store-admin/orderItems.ts
Normal 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);
|
||||
}
|
||||
@@ -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%.
|
||||
*
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
Reference in New Issue
Block a user