ui fix
This commit is contained in:
@@ -319,6 +319,7 @@ export interface RiderShift {
|
|||||||
*/
|
*/
|
||||||
export interface NewRiderShift {
|
export interface NewRiderShift {
|
||||||
applocationid: number;
|
applocationid: number;
|
||||||
|
shiftname?: string;
|
||||||
starttime: string;
|
starttime: string;
|
||||||
endtime: string;
|
endtime: string;
|
||||||
basefare?: number;
|
basefare?: number;
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import type {
|
|||||||
DeliveryRow,
|
DeliveryRow,
|
||||||
DeliverySummary,
|
DeliverySummary,
|
||||||
LocationOrderSummary,
|
LocationOrderSummary,
|
||||||
|
OrderItem,
|
||||||
OrderRow,
|
OrderRow,
|
||||||
OrderSummary,
|
OrderSummary,
|
||||||
PosLocationHealth,
|
PosLocationHealth,
|
||||||
@@ -163,6 +164,32 @@ export const insightsApi = {
|
|||||||
...range,
|
...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 = {}) =>
|
deliverySummary: (tenantid: number, range: DateRange = {}) =>
|
||||||
api.get<DeliverySummary>(`${WEB}/deliveries/deliverysummary`, { tenantid, ...range }),
|
api.get<DeliverySummary>(`${WEB}/deliveries/deliverysummary`, { tenantid, ...range }),
|
||||||
|
|
||||||
|
|||||||
@@ -192,6 +192,24 @@ export const posUsersApi = {
|
|||||||
locationid,
|
locationid,
|
||||||
})
|
})
|
||||||
.then((page) => (Array.isArray(page?.shifts) ? page.shifts : [])),
|
.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;
|
token?: string;
|
||||||
/** Unix seconds. The tab closing normally ends the session well before this. */
|
/** Unix seconds. The tab closing normally ends the session well before this. */
|
||||||
tokenexpiresat?: number;
|
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 type { ReactNode } from 'react';
|
||||||
import { ArrowDown, Bike, Check, MapPin, Phone, X } from 'lucide-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 { orderStage, type Stage } from './orderProgress';
|
||||||
import { useDeliveryMoves } from './DeliveryProgress';
|
import { useDeliveryMoves } from './DeliveryProgress';
|
||||||
import { Drawer } from './Drawer';
|
import { Drawer } from './Drawer';
|
||||||
import { Badge, DrawerButton, DrawerCard, Metric, Metrics, Note, Row, Section } from './drawerKit';
|
import { Badge, DrawerButton, DrawerCard, Metric, Metrics, Note, Row, Section } from './drawerKit';
|
||||||
import { branchLabel, moneyExact } from './format';
|
import { branchLabel, moneyExact } from './format';
|
||||||
|
import { lineTotal, qtyLabel, summarise } from './orderItems';
|
||||||
|
import { useOrderItems } from '@/queries/hooks';
|
||||||
import {
|
import {
|
||||||
DELIVERY_STATUS,
|
DELIVERY_STATUS,
|
||||||
ORDER_STATUS,
|
ORDER_STATUS,
|
||||||
@@ -135,6 +137,30 @@ function Sheet({
|
|||||||
const value = job ? (job.deliveryamt ?? 0) : orderValue(row);
|
const value = job ? (job.deliveryamt ?? 0) : orderValue(row);
|
||||||
const cancelled = (status ?? '').toLowerCase().includes('cancel');
|
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 (
|
return (
|
||||||
<Drawer
|
<Drawer
|
||||||
title={title}
|
title={title}
|
||||||
@@ -152,7 +178,13 @@ function Sheet({
|
|||||||
Three equal columns, the shape the spec asks for and the one that
|
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". */}
|
answers "what is this worth, who pays what, how much of it". */}
|
||||||
<Metrics>
|
<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 ? (
|
{job ? (
|
||||||
<Metric
|
<Metric
|
||||||
label="Rider charge"
|
label="Rider charge"
|
||||||
@@ -166,11 +198,10 @@ function Sheet({
|
|||||||
isSmall
|
isSmall
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<Metric
|
{/* Counted from the lines when the row carries no count. `itemcount`
|
||||||
label="Items"
|
and `quantity` are both routinely absent on the list read, which is
|
||||||
value={job ? String(job.itemcount ?? '—') : String(orderQuantity(row) || '—')}
|
what made this a dash on an order that plainly had something in it. */}
|
||||||
isSmall
|
<Metric label="Items" value={itemCount > 0 ? String(itemCount) : '—'} isSmall />
|
||||||
/>
|
|
||||||
</Metrics>
|
</Metrics>
|
||||||
|
|
||||||
<Section title="Order journey">
|
<Section title="Order journey">
|
||||||
@@ -262,10 +293,7 @@ function Sheet({
|
|||||||
</Section>
|
</Section>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<Note>
|
<OrderItems items={items} isLoading={detail.isLoading} isError={detail.isError} />
|
||||||
Line items are not in the list read — Fiesta returns them from the order-detail endpoint,
|
|
||||||
which this sheet does not call yet.
|
|
||||||
</Note>
|
|
||||||
</Drawer>
|
</Drawer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -447,3 +475,95 @@ const km = (value: string | undefined): string => {
|
|||||||
const n = Number(value);
|
const n = Number(value);
|
||||||
return Number.isFinite(n) && n > 0 ? `${n.toFixed(1)} km` : '—';
|
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 { useState, type FormEvent } from 'react';
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
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 { errorMessage } from '@/api/client';
|
||||||
import { normaliseMobile, posUsersApi, staffApi, weekdayLabel } from '@/api/people';
|
import { normaliseMobile, posUsersApi, staffApi, weekdayLabel } from '@/api/people';
|
||||||
import type { PosRole, PosUser, StaffInfo, StaffShift, TenantLocation } from '@/api/types';
|
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. */
|
/** The credential the backend hands back on create. Shown once, never re-read. */
|
||||||
const [issued, setIssued] = useState<string | null>(null);
|
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({
|
const save = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
const status = isActive ? 'Active' : 'Inactive';
|
const status = isActive ? 'Active' : 'Inactive';
|
||||||
@@ -517,6 +551,44 @@ export function TillDrawer({
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</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>
|
||||||
|
|
||||||
<Section title="Access">
|
<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
|
* 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.
|
* 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) =>
|
const setShiftField = (key: keyof typeof newShift) => (value: string) =>
|
||||||
setNewShift((prev) => ({ ...prev, [key]: value }));
|
setNewShift((prev) => ({ ...prev, [key]: value }));
|
||||||
|
|
||||||
@@ -133,6 +133,7 @@ export function RiderDrawer({ row, owner, branch, onClose }: RiderDrawerProps) {
|
|||||||
mutationFn: () =>
|
mutationFn: () =>
|
||||||
ridersApi.createShift({
|
ridersApi.createShift({
|
||||||
applocationid,
|
applocationid,
|
||||||
|
shiftname: newShift.shiftname.trim(),
|
||||||
starttime: newShift.starttime.trim(),
|
starttime: newShift.starttime.trim(),
|
||||||
endtime: newShift.endtime.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
|
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. */}
|
to end up with four overlapping windows nobody can tell apart. */}
|
||||||
{hasNoShifts && applocationid > 0 ? (
|
{hasNoShifts && applocationid > 0 ? (
|
||||||
<DrawerCard tone="subtle">
|
<>
|
||||||
<Note>
|
<Note>
|
||||||
No shift windows exist in this region yet, and a rider cannot be hired without
|
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.
|
one. Set the hours they work — every rider in this region can then be put on it.
|
||||||
</Note>
|
</Note>
|
||||||
|
<Field
|
||||||
|
label="Name"
|
||||||
|
isRequired
|
||||||
|
description="What people call it — Morning, Evening, Weekend cover."
|
||||||
|
>
|
||||||
|
<TextField value={newShift.shiftname} onChange={setShiftField('shiftname')} />
|
||||||
|
</Field>
|
||||||
<FieldRow>
|
<FieldRow>
|
||||||
<Field label="Starts" isRequired>
|
<Field label="Starts" isRequired>
|
||||||
<TextField value={newShift.starttime} onChange={setShiftField('starttime')} />
|
<TextField value={newShift.starttime} onChange={setShiftField('starttime')} />
|
||||||
@@ -326,12 +334,13 @@ export function RiderDrawer({ row, owner, branch, onClose }: RiderDrawerProps) {
|
|||||||
icon={<Clock size={15} />}
|
icon={<Clock size={15} />}
|
||||||
isDisabled={
|
isDisabled={
|
||||||
addShift.isPending ||
|
addShift.isPending ||
|
||||||
|
newShift.shiftname.trim() === '' ||
|
||||||
newShift.starttime.trim() === '' ||
|
newShift.starttime.trim() === '' ||
|
||||||
newShift.endtime.trim() === ''
|
newShift.endtime.trim() === ''
|
||||||
}
|
}
|
||||||
onClick={() => addShift.mutate()}
|
onClick={() => addShift.mutate()}
|
||||||
/>
|
/>
|
||||||
</DrawerCard>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{/* A merchant's rider works out of one of that merchant's branches.
|
{/* 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>
|
</Section>
|
||||||
|
|
||||||
{!isNew ? (
|
{!isNew ? (
|
||||||
<Section title="Status">
|
<Section title="Access">
|
||||||
<Field label="Status">
|
<ChoiceGroup>
|
||||||
<SelectField
|
<Choice
|
||||||
value={form.status}
|
label="Active"
|
||||||
onChange={set('status')}
|
note="Can be given work"
|
||||||
options={[
|
isSelected={form.status.toLowerCase() !== 'inactive'}
|
||||||
{ value: 'Active', label: 'Active' },
|
onSelect={() => set('status')('Active')}
|
||||||
{ value: 'InActive', label: 'Inactive — cannot be given work' },
|
|
||||||
]}
|
|
||||||
/>
|
/>
|
||||||
</Field>
|
<Choice
|
||||||
|
label="Inactive"
|
||||||
|
note="Cannot be given work"
|
||||||
|
isSelected={form.status.toLowerCase() === 'inactive'}
|
||||||
|
onSelect={() => set('status')('InActive')}
|
||||||
|
/>
|
||||||
|
</ChoiceGroup>
|
||||||
</Section>
|
</Section>
|
||||||
) : null}
|
) : 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%
|
* shift_name / shift_start / shift_end 0%
|
||||||
*
|
*
|
||||||
* The old table spent a sixth of its width on a Shift column. Those fields
|
* 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 —
|
* exist on the model with `omitempty` and were filled for nobody —
|
||||||
* `getstaffshifts` answers `{"shifts": []}` at every branch — so the column
|
* `getstaffshifts` answered `{"shifts": []}` at every branch — so the column
|
||||||
* read "Any" on every row of every shop. A shift now rides under the name,
|
* 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%.
|
* `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),
|
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. */
|
/** The directory — everyone, working today or not. */
|
||||||
riderRoster: (tenantid: number) =>
|
riderRoster: (tenantid: number) =>
|
||||||
[...queryKeys.insights.all, 'rider-roster', tenantid] as const,
|
[...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) =>
|
riderShifts: (applocationid: number) =>
|
||||||
[...queryKeys.insights.all, 'rider-shifts', applocationid] as const,
|
[...queryKeys.insights.all, 'rider-shifts', applocationid] as const,
|
||||||
partners: (applocationid: number) =>
|
partners: (applocationid: number) =>
|
||||||
|
|||||||
Reference in New Issue
Block a user