rider creation

This commit is contained in:
2026-09-04 16:03:46 +05:30
parent 416c50755b
commit e1a16377ea
17 changed files with 2259 additions and 33 deletions

View File

@@ -35,13 +35,20 @@ export const RIDER_MESSAGE = {
export interface RiderQuery {
/**
* The delivery region. This is the scope that works.
* The delivery region, and for now the only scope that finds anybody.
*
* `tenantid` is also accepted and returns nothing: the filter is
* `app_users.tenantid`, which is not set on rider accounts. Verified against
* production — `?tenantid=1135` gives an empty list while `?applocationid=1`
* gives the rider working that tenant's shops. Passing the tenant would have
* produced an empty picker with no error to explain it.
* `tenantid` is accepted too and the query is sound — it just matches nothing
* yet, because `app_users.tenantid` was never filled in for a rider. Riders
* hired through this console DO carry one, so tenant scope starts working the
* moment a merchant has their own.
*
* It is not the scope used here, and that is deliberate: production has 84
* riders on applocation 1 and none of them has a tenant, so switching today
* would empty the picker for everybody. Revisit once merchants have hired
* their own — preferring tenant and falling back to region.
*
* Note what region means: a CITY. Until then an operator is offered every
* on-duty rider in Coimbatore, including other merchants'.
*/
applocationid: number;
}
@@ -135,3 +142,114 @@ export interface UpdateDelivery {
deliverytime?: string;
canceltime?: string;
}
/* ── Riders as people, not as a fleet ────────────────────────────────────── */
/**
* One rider being hired.
*
* Flat, though it lands in three tables — `app_users` for the person,
* `ridersettings` for the vehicle and licence, `app_userpools` for their place
* in the availability pool. The caller should not have to know the table layout
* to hire somebody, and the backend writes all three in one transaction.
*
* `tenantid` is NOT here. It goes on the query string and the backend takes it
* from there, so a payload cannot put a rider on another merchant's books.
*/
export interface NewRider {
userid?: number;
firstname: string;
lastname?: string;
contactno: string;
email?: string;
password?: string;
/** The delivery region. Defaulted from the branch — see `RiderDrawer`. */
applocationid: number;
partnerid?: number;
shiftid: number;
identificationno?: string;
vehiclename?: string;
vehicleno?: string;
licenseno?: string;
registrationno?: string;
status?: string;
}
/**
* One rider in the directory.
*
* `isonduty` is the field to read for "are they working right now" — `onduty`
* is the availability flag, which is 1 for anyone who may be given work at all.
* A rider hired this morning has `onduty: 1` and `isonduty: false` until they
* open the app and start a shift.
*/
export interface RiderRosterRow {
userid: number;
firstname?: string;
lastname?: string;
fullname?: string;
contactno?: string;
email?: string;
tenantid?: number;
applocationid?: number;
applocation?: string;
partnerid?: number;
partnername?: string;
shiftid?: number;
shiftname?: string;
identificationno?: string;
vehiclename?: string;
vehicleno?: string;
licenseno?: string;
registrationno?: string;
/** May be given work at all. */
onduty?: number;
lastlogdate?: string;
/** On shift right now — a log dated today. */
isonduty?: boolean;
status?: string;
}
export interface Partner {
partnerid: number;
partnername?: string;
applocationid?: number;
contactno?: string;
city?: string;
}
export interface RiderShift {
shiftid: number;
shiftname?: string;
starttime?: string;
endtime?: string;
shifthours?: number;
}
export const ridersApi = {
/**
* The directory — everyone, working today or not.
*
* NOT `getriders`, which requires a clock-in dated today. That one answers
* "who can take this delivery now" and is right for the assign picker; used
* as a staff list it hides the rider you just created, which reads as a
* failed save.
*/
roster: (tenantid: number) =>
api.list<RiderRosterRow>(`${WEB}/partners/getriderroster`, { tenantid }),
/** Hire one. `tenantid` travels as a param — the backend ignores it in the body. */
create: (tenantid: number, rider: NewRider) =>
api.post<{ userid: number }>(`${WEB}/partners/createrider`, rider, { tenantid }),
update: (rider: NewRider & { userid: number }) =>
api.put<unknown>(`${WEB}/partners/updaterider`, rider),
/** Shifts to choose from. Scoped by region, and the param is required. */
shifts: (applocationid: number) =>
api.list<RiderShift>(`${WEB}/partners/getridershifts`, { applocationid }),
/** Delivery partners a rider can ride for. */
partners: (applocationid: number) =>
api.list<Partner>(`${WEB}/partners/getpartners`, { applocationid }),
};

118
src/api/nutrition.ts Normal file
View File

@@ -0,0 +1,118 @@
/**
* Health scores and nutrition, from the catalogue-intelligence service.
*
* A SEPARATE HOST from Fiesta — `mcp.nearle.ai.in`, the same service that
* scrapes the global catalogue — so it does not go through `client.ts`, which
* exists to talk to one backend. It is read-only and unauthenticated, like the
* catalogue reads beside it.
*
* ── The join key ────────────────────────────────────────────────────────────
*
* `image_id`, not `catalogueid`. That is the same stable key the catalogue
* import already uses, and for the same reason: `catalogueid` is renumbered on
* every re-scrape, so a link made through it goes stale silently. A product
* carries its `imageid` from the import, and that is what resolves here.
*
* ── Two things measured against the live service, 4 Sep 2026 ────────────────
*
* - `include_unknown=true` is REQUIRED or the list returns nothing. With it,
* 252 items; without it, zero — including products whose `data_status` is
* "verified" and whose score is a real number. The flag reads like it should
* only add unscored rows; in practice its absence removes everything.
*
* - Scoring covers ten brands (Nestle, Amul, Coca-Cola, Cadbury and six
* smaller ones). None of the brands our merchants actually stock are among
* them yet, so today this renders on no products at all. The wiring is
* correct; the data has to catch up.
*/
const NUTRITION_BASE = 'https://mcp.nearle.ai.in/api';
/** How confident the service is that it matched the right source record. */
export const LOW_CONFIDENCE = 0.7;
export interface NutritionScore {
brand?: string;
image_id?: string;
product_name?: string;
category?: string;
/** 0–100. `null` when the product is known but has not been scored. */
health_score?: number | null;
nutrition_score?: number | null;
health_band?: string | null;
scoring_version?: string | null;
/** Sentences, already written for a person. Rendered as given. */
positive_insights?: string[];
nutritional_cautions?: string[];
ai_summary?: string | null;
diet_tags?: string[];
allergens?: string[];
/** "verified" when the source record was confirmed. */
data_status?: string | null;
data_source?: string | null;
source_url?: string | null;
/**
* 0–1. The 5 Star record scores 0.577 — a moderate match, not a certainty.
*
* Surfaced rather than hidden. A nutrition panel presented as fact when the
* underlying match is a guess is worse than no panel, and that goes double
* for the allergen list.
*/
match_confidence?: number | null;
serving_size_g?: number | null;
serving_size_label?: string | null;
calories_kcal?: number | null;
protein_g?: number | null;
carbohydrates_g?: number | null;
total_sugar_g?: number | null;
added_sugar_g?: number | null;
dietary_fiber_g?: number | null;
total_fat_g?: number | null;
saturated_fat_g?: number | null;
sodium_mg?: number | null;
}
async function read<T>(path: string): Promise<T | null> {
let response: Response;
try {
response = await fetch(`${NUTRITION_BASE}${path}`, {
headers: { Accept: 'application/json' },
});
} catch {
// A nutrition panel is an enhancement on a product page. If the service is
// unreachable the page still has to render, so this reports "nothing"
// rather than throwing into the drawer.
return null;
}
if (!response.ok) return null;
try {
return (await response.json()) as T;
} catch {
return null;
}
}
export const nutritionApi = {
/**
* One product's score and nutrition.
*
* Returns null when the product is unknown to the service, and a record with
* `health_score: null` when it is known but unscored — two different answers
* that must not be collapsed, because the second means "coming soon" and the
* first means "this product was never in the catalogue".
*/
forProduct: (brand: string, imageId: string) =>
read<NutritionScore>(
`/nutrition/${encodeURIComponent(brand)}/${encodeURIComponent(imageId)}`,
),
};
/** True when the service knows the product but has not scored it yet. */
export function isUnscored(score: NutritionScore | null): boolean {
return score !== null && (score.health_score === null || score.health_score === undefined);
}

View File

@@ -20,7 +20,7 @@
* know about it.
*/
import { api, WEB } from './client';
import { api, MOB, WEB } from './client';
import type {
ImportCatalogueProductRequest,
Product,
@@ -281,3 +281,66 @@ export async function importSheetProducts(
failures,
};
}
/* ── Variants: one product, several sizes ────────────────────────────────── */
/**
* A size under a parent product.
*
* `variantproductid` is a REAL product row — its own price, its own stock, its
* own barcode — which is why a variant carries none of them. That is the whole
* design: "Cadbury 5 Star 18g" and "9.8g" stay two products the shop counts
* separately, and the app shows one card with a size picker.
*
* `variantname` is what the picker shows. It is free text rather than derived
* from the product name, because "Aachi Baby Fryums 500g" should read as "500g"
* in a row of three buttons, not repeat the brand three times.
*/
export interface ProductVariantLink {
variantid?: number;
tenantid: number;
/** The parent — the product the app shows. */
productid: number;
/** The product actually added to the basket for this size. */
variantproductid: number;
variantname: string;
varianttype?: string;
}
export const variantsApi = {
/**
* Group a product under a parent.
*
* The backend refuses a self-reference, a parent or child belonging to
* another tenant, and a duplicate link — so the console does not need to
* re-check any of that, only to show the reason.
*/
add: (link: ProductVariantLink) =>
api.post<ProductVariantLink>(`${WEB}/products/addproductvariant`, link),
/**
* Ungroup. The product itself is untouched — only the link goes.
*
* Keyed on `variantid`, the link's own id, not on the two product ids. The
* backend refuses anything else with "tenantid and variantid are both
* required", so the caller has to have read the link before it can drop it.
*/
remove: (params: { tenantid: number; variantid: number }) =>
api.del<unknown>(`${WEB}/products/removeproductvariant`, undefined, params),
};
/**
* The sizes under one product, as the customer app receives them.
*
* `/v1/mob`, not `/v1/web` — this endpoint exists only on the mobile group, and
* calling the web path 404s. Reading it from the console is deliberate: it is
* the only way to show a merchant exactly what a shopper will see, rather than
* a second rendering of the same links that can drift from it.
*
* The first entry is the PARENT ITSELF. A parent is one of its own sizes, so a
* picker of three has three entries, not a parent plus two.
*/
export const variantPreviewApi = {
forProduct: (params: { productid: number; tenantid: number; locationid: number }) =>
api.list<Product>(`${MOB}/products/getproductbyvariant`, params),
};

View File

@@ -211,8 +211,31 @@ export interface Product {
productsku?: string;
brandid?: number;
productbrand?: string;
/**
* The stable global-catalogue key, carried across by the import.
*
* Not `catalogueid`, which is renumbered on every re-scrape — 11 of 19 links
* were already broken by that. This is what joins a tenant's product back to
* the catalogue, and it is also the key the health-score service uses.
*
* Empty for anything typed in or imported from a sheet, which is most of the
* catalogue: measured 4 Sep 2026, R mart carries it on 21 products of 28,
* Suriya Store on 2 of 8, K mart on none.
*/
imageid?: string;
productunit?: string;
unitvalue?: string;
/**
* The size label, present only on rows from `getproductbyvariant`.
*
* What a shopper taps in the size picker — "500g", not the whole product
* name. The backend derives the PARENT's own label from
* `unitvalue + productunit` and falls back to the product name when both are
* blank, so a product imported without a unit shows its full name in the
* picker. Worth knowing when a picker reads badly: the fix is the product's
* unit, not the link.
*/
variantname?: string;
productcost?: number;
taxamount?: number;
taxpercent?: number;

View File

@@ -0,0 +1,176 @@
import { useQuery } from '@tanstack/react-query';
import { Text } from '@astryxdesign/core/Text';
import { VStack } from '@astryxdesign/core/VStack';
import { AlertTriangle, Check, ExternalLink, Leaf } from 'lucide-react';
import { nutritionApi } from '@/api/nutrition';
import type { Product } from '@/api/types';
import { BAND_COLOR, BAND_LABEL, facts, present } from './healthScore';
import './pages/deliveries.css';
/**
* The health score, on a product page.
*
* Read from the catalogue-intelligence service, keyed on `image_id` — the
* stable catalogue key, not `catalogueid`, which is renumbered on every
* re-scrape.
*
* ── Three states, and they are not interchangeable ──────────────────────────
*
* - No `imageid` on the product: it never came from the global catalogue, so
* there is nothing to look up. The panel is absent rather than empty.
* - Known but unscored: the service has the product and no score yet. Said
* plainly, because this is every catalogue-linked product today and a
* merchant should not read it as a fault.
* - Scored: the score, what is good, what to watch, and the figures.
*
* The panel never invents a score, and never rounds a weak match into a
* confident one — see the caveat, which is shown whenever the service's own
* match confidence is below 70%.
*/
export function HealthScorePanel({ product }: { product: Product }) {
const brand = (product.productbrand ?? '').trim();
const imageId = (product.imageid ?? '').trim();
const query = useQuery({
queryKey: ['nutrition', brand, imageId],
queryFn: () => nutritionApi.forProduct(brand, imageId),
enabled: Boolean(brand && imageId),
// Nutrition for a packaged product does not change during a trading day.
staleTime: 60 * 60_000,
refetchOnWindowFocus: false,
// The service being down must not retry three times behind a drawer the
// merchant is already reading.
retry: false,
});
// Nothing to look up. Not an error and not worth a line of chrome saying so:
// most products in this catalogue were typed in or imported from a sheet.
if (!brand || !imageId) return null;
if (query.isLoading) {
return (
<VStack gap={1}>
<Label />
<Text type="body" size="sm" color="secondary">
Checking the health score…
</Text>
</VStack>
);
}
const shown = present(query.data ?? null);
if (shown.isEmpty) return null;
if (shown.isPending) {
return (
<VStack gap={1}>
<Label />
<Text type="body" size="sm" color="secondary" style={{ lineHeight: 1.6 }}>
This product is in the catalogue but has not been scored yet. It will appear here, and in
the app, once it has.
</Text>
</VStack>
);
}
const colour = shown.band ? BAND_COLOR[shown.band] : 'var(--color-ink-3)';
return (
<VStack gap={1}>
<Label />
<div className="health-head">
<span
className="health-score"
style={{ color: colour, background: `color-mix(in oklab, ${colour} 12%, transparent)` }}
>
{shown.display}
<em>/100</em>
</span>
<span className="health-band" style={{ color: colour }}>
{shown.band ? BAND_LABEL[shown.band] : ''}
</span>
</div>
{/* The service writes these sentences itself. Rendered as given rather
than re-worded — it computed them from the figures, and paraphrasing
would put our words on its arithmetic. */}
{shown.good.length > 0 ? (
<ul className="health-points" data-tone="good">
{shown.good.map((line) => (
<li key={line}>
<Check size={13} />
{line}
</li>
))}
</ul>
) : null}
{shown.cautions.length > 0 ? (
<ul className="health-points" data-tone="caution">
{shown.cautions.map((line) => (
<li key={line}>
<AlertTriangle size={13} />
{line}
</li>
))}
</ul>
) : null}
{shown.tags.length > 0 ? (
<div className="health-tags">
{shown.tags.map((tag) => (
<span key={tag} className="health-tag">
<Leaf size={11} />
{tag}
</span>
))}
</div>
) : null}
{/* Allergens are their own row, never mixed into the diet tags. One is a
selling point and the other is a warning, and a shopper scanning for
"contains nuts" must not have to read past "Vegetarian" to find it. */}
{shown.allergens.length > 0 ? (
<div className="health-allergens">
<AlertTriangle size={14} />
<span>Contains {shown.allergens.join(', ')}</span>
</div>
) : null}
{facts(query.data ?? null).length > 0 ? (
<div className="health-facts">
{facts(query.data ?? null).map((row) => (
<div key={row.label}>
<span>{row.label}</span>
<strong>{row.value}</strong>
</div>
))}
<p>per 100 g</p>
</div>
) : null}
{shown.caveat ? (
<Text type="body" size="xsm" color="secondary" style={{ lineHeight: 1.55 }}>
{shown.caveat}
</Text>
) : null}
{shown.source ? (
<a className="health-source" href={shown.source.url} target="_blank" rel="noreferrer">
Source: {shown.source.label}
<ExternalLink size={11} />
</a>
) : null}
</VStack>
);
}
function Label() {
return (
<Text type="label" size="xsm" color="secondary">
Health score
</Text>
);
}

View File

@@ -179,10 +179,20 @@ function Problem({ message }: { message: string }) {
* nothing on the way there says so. Naming the consequence in the option is the
* cheapest possible fix.
*/
const STAFF_ROLES = [
{ id: 3, label: 'Administrator — runs the whole business' },
{ id: 4, label: 'Manager — one shop' },
];
/*
* Administrator (roleid 3) is NOT offered here, and its absence is the point.
*
* An administrator is provisioned by Nearle Admin when the business is created
* — `createtenantuser` writes that account and forces the role — so offering it
* here was a second, unguarded way to mint one, from inside the workspace it
* grants the run of.
*
* Editing an existing administrator is unaffected: `roleOptions` below appends
* whatever role the person already holds when this list does not know it, which
* exists precisely so editing a phone number cannot silently reassign someone.
* Removing the option from CREATION therefore costs nothing on the edit path.
*/
const STAFF_ROLES = [{ id: 4, label: 'Manager — one shop' }];
export function PersonDrawer({
row,

View File

@@ -13,6 +13,8 @@ import { useCatalogueProduct } from '@/queries/hooks';
import { useBranchScope } from './BranchScope';
import { Drawer } from './Drawer';
import { branchLabel, count, money } from './format';
import { HealthScorePanel } from './HealthScorePanel';
import { ProductSizes } from './ProductSizes';
import {
blockedReason,
effectivePrice,
@@ -127,6 +129,23 @@ export function ProductDrawer({
</VStack>
) : null}
{/* ── Sizes ─────────────────────────────────────────────────────── */}
{/* Above "In your shops" because it changes what a SHOPPER meets, which
is the bigger fact about a product than where it is stocked. */}
{tenantid && branch ? (
<ProductSizes
product={product}
tenantid={tenantid}
locationid={branch.locationid}
canManage={canManage}
/>
) : null}
{/* ── Health score ──────────────────────────────────────────────── */}
{/* Under Sizes and above the shop detail: it is what a SHOPPER reads,
and it belongs with the other shopper-facing facts. */}
<HealthScorePanel product={product} />
{/* ── In your shops ─────────────────────────────────────────────── */}
<VStack gap={1}>
<Label>In your shops</Label>

View File

@@ -0,0 +1,246 @@
import { useMemo, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Button } from '@astryxdesign/core/Button';
import { HStack } from '@astryxdesign/core/HStack';
import { Selector } from '@astryxdesign/core/Selector';
import { Text } from '@astryxdesign/core/Text';
import { TextInput } from '@astryxdesign/core/TextInput';
import { VStack } from '@astryxdesign/core/VStack';
import { Info, Layers, Plus, X } from 'lucide-react';
import { errorMessage } from '@/api/client';
import { variantPreviewApi, variantsApi } from '@/api/products';
import type { Product } from '@/api/types';
import { useLocationProducts } from '@/queries/hooks';
import { queryKeys } from '@/queries/keys';
import { money } from './format';
import { groupCandidates, priceOf, stockOf, suggestVariantName } from './productVariants';
/**
* Sizes of one product — what the shopper meets as a single card.
*
* A shop stocks "Cadbury 5 Star 18g" and "9.8g" as two products, with two
* barcodes, two stock counts and two prices. That is right for the shop and
* wrong for the shopper, who sees the same chocolate twice. Linking them leaves
* both products exactly as they are and adds a relationship: the app shows one
* card with a size picker, and the size a shopper chooses is the product that
* goes in their basket.
*
* ── What this panel shows, and why it reads from the app's own endpoint ─────
*
* The preview comes from `getproductbyvariant` — the call the customer app
* makes — rather than from the links this panel just wrote. Rendering our own
* copy would drift from what a shopper actually sees, and the two things this
* screen exists to answer are "is it grouped" and "what will they see".
*
* The first entry that comes back is the PARENT ITSELF: a parent is one of its
* own sizes, so a picker of three has three entries rather than a product plus
* two extras.
*/
export function ProductSizes({
product,
tenantid,
locationid,
canManage,
}: {
product: Product;
tenantid: number;
locationid: number;
canManage: boolean;
}) {
const client = useQueryClient();
const [adding, setAdding] = useState(false);
const [pick, setPick] = useState('');
const [label, setLabel] = useState('');
const [problem, setProblem] = useState<string | null>(null);
// What the app sees. Not our own render of the links — see the note above.
const preview = useQuery({
queryKey: ['variant-preview', tenantid, locationid, product.productid],
queryFn: () =>
variantPreviewApi.forProduct({ productid: product.productid, tenantid, locationid }),
enabled: Boolean(tenantid && locationid && product.productid),
});
const sizes = preview.data ?? [];
// One entry means the endpoint echoed the product back with no options — it is
// not grouped. Two or more is a real picker.
const isGrouped = sizes.length > 1;
const shelf = useLocationProducts(tenantid, locationid, 0, { allBranches: false });
const linkedIds = useMemo(
() => new Set(sizes.map((size) => size.productid).filter(Boolean)),
[sizes],
);
const candidates = useMemo(
() => groupCandidates(product, shelf.data ?? [], linkedIds),
[product, shelf.data, linkedIds],
);
const chosen = candidates.find((entry) => String(entry.product.productid) === pick);
const add = useMutation({
mutationFn: () => {
if (!chosen) throw new Error('Pick a product first');
return variantsApi.add({
tenantid,
productid: product.productid,
variantproductid: chosen.product.productid,
// The typed label wins; the suggestion is only a starting point, and a
// product named without a size gets no suggestion at all.
variantname: label.trim() || chosen.suggestion || chosen.product.productname || '',
});
},
onSuccess: async () => {
await preview.refetch();
await client.invalidateQueries({ queryKey: queryKeys.products.all });
setAdding(false);
setPick('');
setLabel('');
},
// The backend refuses a self-reference, a cross-tenant link and a duplicate,
// each with its reason in words. Shown as sent.
onError: (error) => setProblem(errorMessage(error)),
});
return (
<VStack gap={1}>
<HStack justify="between" align="center" gap={2} wrap="wrap">
<Text type="label" size="xsm" color="secondary">
Sizes
</Text>
{canManage && !adding ? (
<Button
label={isGrouped ? 'Add a size' : 'Group with another size'}
variant="ghost"
size="sm"
icon={<Plus size={13} />}
onClick={() => {
setAdding(true);
setProblem(null);
}}
/>
) : null}
</HStack>
{preview.isLoading ? (
<Text type="body" size="sm" color="secondary">
Checking…
</Text>
) : isGrouped ? (
<>
<div className="size-list">
{sizes.map((size) => (
<div
key={size.productid}
className="size-row"
data-parent={size.productid === product.productid}
>
<span className="size-label">
{size.variantname || size.productname}
{size.productid === product.productid ? <em>this one</em> : null}
</span>
<span className="size-price">{money(priceOf(size))}</span>
{/* Stock is shown per size because it is what decides whether a
shopper can pick it. A size at zero is dropped by the app the
same way a lone product is — grouping does not rescue it. */}
<span className="size-stock" data-empty={stockOf(size) <= 0}>
{stockOf(size) > 0 ? `${stockOf(size)} in stock` : 'out of stock'}
</span>
</div>
))}
</div>
<div className="size-note">
<Info size={14} />
<Text type="body" size="xsm">
Shoppers see one product with {sizes.length} sizes to choose from. A size with no
stock or no price is left out of the picker.
</Text>
</div>
</>
) : (
<Text type="body" size="sm" color="secondary">
This product stands on its own. Group it with another size and shoppers meet one product
with a picker instead of two that look alike.
</Text>
)}
{adding ? (
<div className="size-add">
<VStack gap={1.5}>
<Selector
label="Which product is another size of this one?"
size="sm"
value={pick}
onChange={(value) => {
const next = String(value);
setPick(next);
// Pre-fill the label from the chosen product's own name, so the
// common case is one click. Overwritable, always.
const entry = candidates.find((c) => String(c.product.productid) === next);
setLabel(entry?.suggestion ?? '');
setProblem(null);
}}
options={candidates.map((entry) => ({
value: String(entry.product.productid),
label: entry.isLikely
? `${entry.product.productname} — looks like a match`
: (entry.product.productname ?? `Product ${entry.product.productid}`),
}))}
placeholder={
shelf.isLoading
? 'Loading your products…'
: candidates.length > 0
? 'Choose a product'
: 'Nothing else in this shop to group with'
}
/>
<TextInput
label="Show it as"
size="sm"
value={label}
onChange={setLabel}
placeholder={chosen ? suggestVariantName(chosen.product.productname) || '500g' : '500g'}
description="What the shopper taps — the size alone, not the whole product name."
/>
{problem ? (
<Text
type="body"
size="sm"
role="alert"
style={{ color: 'var(--color-error, #d64545)' }}
>
{problem}
</Text>
) : null}
<HStack justify="between" align="center" gap={2} wrap="wrap">
<Button
label="Cancel"
variant="ghost"
size="sm"
icon={<X size={13} />}
onClick={() => {
setAdding(false);
setPick('');
setLabel('');
setProblem(null);
}}
/>
<Button
label={add.isPending ? 'Grouping…' : 'Group them'}
variant="primary"
size="sm"
icon={<Layers size={13} />}
isDisabled={!chosen || add.isPending}
onClick={() => add.mutate()}
/>
</HStack>
</VStack>
</div>
) : null}
</VStack>
);
}

View File

@@ -0,0 +1,258 @@
import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Button } from '@astryxdesign/core/Button';
import { HStack } from '@astryxdesign/core/HStack';
import { Selector } from '@astryxdesign/core/Selector';
import { Text } from '@astryxdesign/core/Text';
import { TextInput } from '@astryxdesign/core/TextInput';
import { VStack } from '@astryxdesign/core/VStack';
import { Bike, Info } from 'lucide-react';
import { errorMessage } from '@/api/client';
import { ridersApi, type RiderRosterRow } from '@/api/deliveries';
import type { TenantLocation } from '@/api/types';
import { usePartners, useRiderShifts } from '@/queries/hooks';
import { queryKeys } from '@/queries/keys';
import { Drawer } from './Drawer';
import './pages/deliveries.css';
/**
* Hiring a rider, or editing one.
*
* A rider is three database rows — the person, their vehicle and licence, and
* their place in the availability pool — written in one transaction by
* `partners/createrider`. Before that endpoint existed the only way to make one
* was `users/create`, which writes the first row and stops: it answered 201 and
* produced somebody every rider query ignored forever.
*
* ── The thing this form has to say out loud ─────────────────────────────────
*
* A new rider does NOT appear in the on-duty fleet until they open the rider app
* and start a shift, because that list requires a clock-in stamped today. That
* is correct behaviour and it looks exactly like a failed save, so the drawer
* says it before you press the button rather than leaving you to wonder.
*/
export interface RiderDrawerProps {
row: RiderRosterRow | null;
tenantid: number;
/** The branch whose delivery region a new rider inherits. */
branch: TenantLocation | undefined;
onClose: () => void;
}
export function RiderDrawer({ row, tenantid, branch, onClose }: RiderDrawerProps) {
const client = useQueryClient();
const isNew = row === null;
/*
* The delivery region is inherited, not chosen.
*
* It is a CITY, and a merchant has no business picking one from a list — the
* branch they are standing in already determines it. Editing keeps whatever
* the rider has; a new rider takes the branch's.
*/
const applocationid = row?.applocationid ?? branch?.applocationid ?? 0;
const shifts = useRiderShifts(applocationid || undefined);
const partners = usePartners(applocationid || undefined);
const [form, setForm] = useState({
firstname: row?.firstname ?? '',
lastname: row?.lastname ?? '',
contactno: row?.contactno ?? '',
email: row?.email ?? '',
identificationno: row?.identificationno ?? '',
vehiclename: row?.vehiclename ?? '',
vehicleno: row?.vehicleno ?? '',
licenseno: row?.licenseno ?? '',
registrationno: row?.registrationno ?? '',
shiftid: row?.shiftid ? String(row.shiftid) : '',
partnerid: row?.partnerid ? String(row.partnerid) : '',
status: row?.status ?? 'Active',
});
const [problem, setProblem] = useState<string | null>(null);
const set = (key: keyof typeof form) => (value: string) =>
setForm((prev) => ({ ...prev, [key]: value }));
const save = useMutation({
mutationFn: () => {
const payload = {
firstname: form.firstname.trim(),
lastname: form.lastname.trim(),
contactno: form.contactno.trim(),
email: form.email.trim(),
applocationid,
partnerid: Number(form.partnerid) || 0,
shiftid: Number(form.shiftid) || 0,
identificationno: form.identificationno.trim(),
vehiclename: form.vehiclename.trim(),
vehicleno: form.vehicleno.trim(),
licenseno: form.licenseno.trim(),
registrationno: form.registrationno.trim(),
status: form.status,
};
return isNew
? ridersApi.create(tenantid, payload)
: ridersApi.update({ ...payload, userid: row.userid });
},
onSuccess: async () => {
await client.invalidateQueries({ queryKey: queryKeys.insights.all });
onClose();
},
// The backend answers 400 with the reason in words — a shift that does not
// exist, an unconfigured region, a number already in use — so it is shown
// as-is rather than replaced with a generic failure.
onError: (error) => setProblem(errorMessage(error)),
});
const canSave =
form.firstname.trim() !== '' &&
form.contactno.trim() !== '' &&
Number(form.shiftid) > 0 &&
applocationid > 0 &&
!save.isPending;
return (
<Drawer
title={isNew ? 'Add a rider' : row.fullname || 'Rider'}
subtitle={isNew ? 'They deliver for this shop' : row.contactno}
width={480}
onClose={onClose}
>
<VStack gap={3}>
{/* The region is stated, not offered. If the branch has none, saying so
here beats a 400 after the form is filled in. */}
{applocationid === 0 ? (
<div className="rider-note" data-tone="stop">
<Info size={15} />
<Text type="body" size="sm">
This branch has no delivery region set, so a rider added to it would not appear
anywhere. Set the region on the branch first.
</Text>
</div>
) : null}
<VStack gap={1.5}>
<HStack gap={1.5} wrap="wrap">
<TextInput label="First name" value={form.firstname} onChange={set('firstname')} />
<TextInput label="Last name" value={form.lastname} onChange={set('lastname')} />
</HStack>
<TextInput
label="Phone"
value={form.contactno}
onChange={set('contactno')}
description="How the shop reaches them, and how the app signs them in."
/>
<TextInput label="Email" value={form.email} onChange={set('email')} />
</VStack>
<VStack gap={1.5}>
<Text type="label" size="xsm" color="secondary">
Shift and partner
</Text>
<Selector
label="Shift"
size="sm"
value={form.shiftid}
onChange={(value) => set('shiftid')(String(value))}
options={(shifts.data ?? []).map((shift) => ({
value: String(shift.shiftid),
label: shift.shiftname || `${shift.starttime} – ${shift.endtime}`,
}))}
placeholder={
shifts.isLoading
? 'Loading shifts…'
: (shifts.data ?? []).length > 0
? 'Choose a shift'
: 'No shifts set up for this region'
}
/>
<Selector
label="Delivery partner"
size="sm"
value={form.partnerid}
onChange={(value) => set('partnerid')(String(value))}
options={(partners.data ?? []).map((partner) => ({
value: String(partner.partnerid),
label: partner.partnername || `Partner ${partner.partnerid}`,
}))}
placeholder={
partners.isLoading ? 'Loading partners…' : 'None — rides for the shop directly'
}
/>
</VStack>
<VStack gap={1.5}>
<Text type="label" size="xsm" color="secondary">
Vehicle and papers
</Text>
<HStack gap={1.5} wrap="wrap">
<TextInput
label="Vehicle"
value={form.vehiclename}
onChange={set('vehiclename')}
placeholder="Bike, Scooter…"
/>
<TextInput label="Number plate" value={form.vehicleno} onChange={set('vehicleno')} />
</HStack>
<HStack gap={1.5} wrap="wrap">
<TextInput label="Licence no." value={form.licenseno} onChange={set('licenseno')} />
<TextInput
label="Registration no."
value={form.registrationno}
onChange={set('registrationno')}
/>
</HStack>
<TextInput
label="ID number"
value={form.identificationno}
onChange={set('identificationno')}
/>
</VStack>
{!isNew ? (
<Selector
label="Status"
size="sm"
value={form.status}
onChange={(value) => set('status')(String(value))}
options={[
{ value: 'Active', label: 'Active' },
{ value: 'InActive', label: 'Inactive — cannot be given work' },
]}
/>
) : null}
{problem ? (
<Text type="body" size="sm" role="alert" style={{ color: 'var(--color-error, #d64545)' }}>
{problem}
</Text>
) : null}
<HStack justify="between" align="center" gap={2} wrap="wrap">
<Button label="Cancel" variant="ghost" size="sm" onClick={onClose} />
<Button
label={save.isPending ? 'Saving…' : isNew ? 'Add rider' : 'Save'}
variant="primary"
size="sm"
icon={<Bike size={14} />}
isDisabled={!canSave}
onClick={() => save.mutate()}
/>
</HStack>
{/* Said before the button, not after. This is the behaviour most likely
to be read as a failed save. */}
{isNew ? (
<div className="rider-note">
<Info size={15} />
<Text type="body" size="xsm">
They will show here straight away, but not in the fleet you assign deliveries from
until they sign in to the rider app and start a shift.
</Text>
</div>
) : null}
</VStack>
</Drawer>
);
}

View File

@@ -0,0 +1,229 @@
import { strict as assert } from 'node:assert';
import { test } from 'node:test';
import type { NutritionScore } from '@/api/nutrition';
import { BAND_LABEL, bandFor, facts, isEdible, present } from './healthScore';
/*
The fixture is the service's own record for Cadbury 5 Star, read from
mcp.nearle.ai.in on 4 Sep 2026 — including the parts that make it awkward: a
match confidence of 0.577, an empty ai_summary, and a null added_sugar_g.
*/
const fiveStar: NutritionScore = {
brand: 'Cadbury',
image_id: 'cadbury_cadbury_5_star_200g',
product_name: 'Cadbury 5 Star',
category: 'Chocolates',
nutrition_score: 21.2,
health_score: 18.1,
health_band: null,
positive_insights: [
'Good source of protein (6.8 g per 100 g).',
'Low in sodium (107 mg per 100 g).',
],
nutritional_cautions: [
'Contains high sugar (58.8 g per 100 g).',
'High in saturated fat (18.7 g per 100 g).',
],
ai_summary: '',
diet_tags: ['Low Sodium', 'Vegetarian'],
allergens: [],
data_status: 'verified',
data_source: 'openfoodfacts',
source_url: 'https://world.openfoodfacts.org/product/7622202334009',
match_confidence: 0.577,
calories_kcal: 525.0,
protein_g: 6.8,
carbohydrates_g: 62.6,
total_sugar_g: 58.8,
added_sugar_g: null,
dietary_fiber_g: 0.0,
total_fat_g: 27.9,
saturated_fat_g: 18.7,
sodium_mg: 107,
};
/* ── Three different nothings ────────────────────────────────────────────── */
test('an unknown product renders no panel', () => {
const shown = present(null);
assert.equal(shown.isEmpty, true);
assert.equal(shown.isPending, false);
});
test('a known but unscored product is pending, not empty', () => {
/*
The distinction that matters to a merchant. Every one of R mart's 21
catalogue-linked products comes back like this today — known to the service,
waiting to be scored. Collapsing it into "unknown" would say the product does
not exist.
*/
const shown = present({ brand: 'Aachi', image_id: 'aachi_aachi_baby_fryums_100g', health_score: null });
assert.equal(shown.isEmpty, false);
assert.equal(shown.isPending, true);
assert.equal(shown.score, null);
});
test('a scored product is neither', () => {
const shown = present(fiveStar);
assert.equal(shown.isEmpty, false);
assert.equal(shown.isPending, false);
assert.equal(shown.score, 18.1);
});
/* ── Bands ───────────────────────────────────────────────────────────────── */
test('the service band wins when it sends one', () => {
assert.equal(bandFor(89.9, 'excellent'), 'excellent');
// Even when it disagrees with our thresholds: it is computed from the same
// rules as the score, and ours is only a fallback.
assert.equal(bandFor(10, 'good'), 'good');
});
test('the fallback uses the SERVICE thresholds, not our own', () => {
/*
The per-product endpoint sends no health_band, so this fallback is the only
path the console panel ever takes — the thresholds are load-bearing.
Read off the service's own output: excellent 80.0–89.9, good 60.2–78.8,
fair 50.0–58.9, poor 11.2–38.3. Boundaries at each band's observed minimum.
*/
assert.equal(bandFor(89.9, null), 'excellent');
assert.equal(bandFor(80.0, null), 'excellent');
assert.equal(bandFor(78.8, null), 'good');
assert.equal(bandFor(60.2, null), 'good');
assert.equal(bandFor(58.9, ''), 'fair');
assert.equal(bandFor(50.0, undefined), 'fair');
assert.equal(bandFor(38.3, null), 'poor');
assert.equal(bandFor(18.1, null), 'poor');
});
test('60 is good, because that is the cut the service counts as healthy', () => {
// Confirmed by the team: the same threshold store_healthy_distribution uses.
// A different one here would be the same API disagreeing with itself
// depending which endpoint a screen called.
assert.equal(bandFor(60, null), 'good');
assert.equal(bandFor(59.9, null), 'fair');
});
test('the band is worded for a shopper, not a nutritionist', () => {
assert.equal(BAND_LABEL[bandFor(18.1, null)], 'Less healthy');
assert.equal(BAND_LABEL[bandFor(89.9, null)], 'Very healthy');
});
test('the score is rounded — nobody reads the decimal', () => {
assert.equal(present(fiveStar).display, '18');
});
/* ── Honesty ─────────────────────────────────────────────────────────────── */
test('a weak match is stated, not hidden', () => {
/*
5 Star matched at 0.577. A nutrition table presented as fact on a 58% match
is a claim the data does not support, and the allergen list is the part that
could actually hurt somebody.
*/
const shown = present(fiveStar);
assert.match(shown.caveat, /58% confidence/);
});
test('a confident match carries no caveat', () => {
assert.equal(present({ ...fiveStar, match_confidence: 0.95 }).caveat, '');
});
test('a missing confidence carries no caveat, rather than assuming the worst', () => {
assert.equal(present({ ...fiveStar, match_confidence: null }).caveat, '');
});
test('the source is kept so a shopper can check the claim', () => {
const shown = present(fiveStar);
assert.equal(shown.source?.label, 'openfoodfacts');
assert.match(shown.source?.url ?? '', /openfoodfacts\.org/);
});
test('blank sentences are dropped rather than rendered as empty bullets', () => {
const shown = present({ ...fiveStar, positive_insights: ['Good protein.', '', ' '] });
assert.deepEqual(shown.good, ['Good protein.']);
});
/* ── The label ───────────────────────────────────────────────────────────── */
test('only figures the service actually sent are shown', () => {
// added_sugar_g is null on this record, so there is no "added sugars" row —
// a table of dashes is worse than a shorter table.
const rows = facts(fiveStar);
const labels = rows.map((r) => r.label);
assert.ok(labels.includes('Energy'));
assert.ok(labels.includes('of which sugars'));
assert.ok(!labels.includes('Added sugars'));
});
test('a zero is a real figure and is kept', () => {
// Fibre is 0.0 on 5 Star. Dropping zeros would hide "no fibre", which is
// exactly the fact the cautions are complaining about.
const rows = facts(fiveStar);
assert.equal(rows.find((r) => r.label === 'Fibre')?.value, '0 g');
});
test('trailing zeros are trimmed but real decimals kept', () => {
const rows = facts(fiveStar);
assert.equal(rows.find((r) => r.label === 'Energy')?.value, '525 kcal');
assert.equal(rows.find((r) => r.label === 'Protein')?.value, '6.8 g');
});
test('no record means no table', () => {
assert.deepEqual(facts(null), []);
});
/* ── Not everything scored is food ───────────────────────────────────────── */
test('a health score on something inedible is withheld', () => {
/*
Measured on the live service, 4 Sep 2026:
GET /nutrition/Godrej/godrej_hit_spray_1101d017
→ health_score: 80.0, data_status: "verified",
category: "Personal Care - Mosquito Repellent"
Eighty out of a hundred, "excellent", for insecticide. The list endpoint is
gated on an edibility column; the per-product one this console calls is not.
*/
const hitSpray: NutritionScore = {
brand: 'Godrej',
product_name: 'Godrej Hit Spray',
category: 'Personal Care - Mosquito Repellent',
health_score: 80.0,
data_status: 'verified',
};
const shown = present(hitSpray);
assert.equal(shown.score, null, 'no score is shown');
assert.equal(shown.isPending, true, 'reads as "not scored yet", which is honest');
});
test('the ambiguous "General" category is refused', () => {
// It carries Palmolive soap and P&G alongside genuinely unplaced rows.
assert.equal(isEdible('General'), false);
assert.equal(present({ category: 'General', health_score: 55.3 }).score, null);
});
test('personal and home care are refused', () => {
assert.equal(isEdible('Hair Care'), false);
assert.equal(isEdible('Personal Care - Mosquito Repellent'), false);
assert.equal(isEdible('Home Care'), false);
assert.equal(isEdible(''), false);
assert.equal(isEdible(null), false);
});
test('real food categories from the service are allowed', () => {
// Every one of these is a category the live endpoint actually returns.
for (const category of [
'Beverages', 'Chocolates', 'Dairy', 'Tea & Coffee', 'Health Drinks',
'Candy & Confectionery', 'Atta & Staples', 'Snacks', 'Pasta & Noodles',
'Health Foods', 'Breakfast Cereal', 'Biscuits & Cookies',
]) {
assert.equal(isEdible(category), true, `${category} should be edible`);
}
});
test('a scored chocolate still comes through', () => {
// The guard must not swallow the case it exists to protect.
assert.equal(present(fiveStar).score, 18.1);
});

View File

@@ -0,0 +1,241 @@
import { LOW_CONFIDENCE, type NutritionScore } from '@/api/nutrition';
/**
* Presenting a health score honestly.
*
* The service returns a number out of 100 and a band, plus sentences it has
* already written for a person. This module decides how much of that is safe to
* show, which is not the same question as how much of it exists.
*
* Pure, so the honesty rules can be tested without the network.
*/
export type Band = 'excellent' | 'good' | 'fair' | 'poor';
/**
* The band for a score.
*
* The service's own `health_band` wins when it sends one. It does NOT send one
* from the per-product endpoint — only from the list — so for the console
* panel this fallback is not an edge case, it is the only path. That makes the
* thresholds below load-bearing rather than cosmetic.
*
* They are the SERVICE'S thresholds, not ours, read off its own output:
*
* excellent 80.0 – 89.9
* good 60.2 – 78.8
* fair 50.0 – 58.9
* poor 11.2 – 38.3
*
* So: 80 / 60 / 40. The 60 is confirmed by the team as the same cut
* `store_healthy_distribution` counts as healthy. The fair/poor line is the one
* value the data cannot pin — nothing scores between 38.3 and 50.0 — and 40 is
* what completes that ladder.
*
* Picking our own numbers here would be the same API disagreeing with itself
* depending on which of its endpoints a screen happened to call.
*/
export function bandFor(score: number, sent?: string | null): Band {
const given = (sent ?? '').trim().toLowerCase();
if (given === 'excellent' || given === 'good' || given === 'fair' || given === 'poor') {
return given;
}
if (score >= 80) return 'excellent';
if (score >= 60) return 'good';
if (score >= 40) return 'fair';
return 'poor';
}
/** What a shopper reads, not what a nutritionist would call it. */
export const BAND_LABEL: Record<Band, string> = {
excellent: 'Very healthy',
good: 'Healthy',
fair: 'Okay',
poor: 'Less healthy',
};
/**
* Semantic colour, separate from the brand accent.
*
* Green through amber to red, because this IS a good/bad judgement — the one
* place on a product page where that is the honest reading rather than a
* decoration.
*/
export const BAND_COLOR: Record<Band, string> = {
excellent: '#1c6b47',
good: '#4d7c2f',
fair: '#8a5a00',
poor: '#b3261e',
};
/**
* Category words that mean "this is food or drink".
*
* An ALLOWLIST, and the asymmetry of the two failure modes is why. Hiding a
* score on real food costs a merchant nothing they have today — coverage is
* currently zero against every brand they stock. Showing one on something
* inedible is a different order of mistake.
*/
const FOOD_CATEGORY_WORDS = [
'beverage', 'drink', 'juice', 'water', 'tea', 'coffee',
'chocolate', 'candy', 'confection', 'sweet', 'dessert',
'dairy', 'milk', 'cheese', 'butter', 'ghee', 'curd', 'yogurt',
'snack', 'biscuit', 'cookie', 'wafer', 'chips', 'namkeen',
'atta', 'staple', 'flour', 'rice', 'dal', 'pulse', 'grain', 'cereal',
'pasta', 'noodle', 'bread', 'bakery',
'oil', 'masala', 'spice', 'sauce', 'pickle', 'jam', 'honey',
'food', 'nutrition', 'breakfast', 'fruit', 'vegetable', 'egg', 'meat',
];
/**
* Is this score attached to something a person eats?
*
* TEMPORARY SCAFFOLDING, and it should be deleted the moment the service
* publishes its edibility column on the per-product endpoint.
*
* The reason it cannot wait: measured 4 Sep 2026,
* `GET /nutrition/Godrej/godrej_hit_spray_1101d017` returns
* `health_score: 80.0`, `data_status: "verified"` — an eighty-out-of-a-hundred
* "excellent" health score, for insecticide. Palmolive soap scores 37.5 and
* Pantene shampoo 37.5 through the same route.
*
* The service is fixing this at source: an edibility column gates the LIST
* endpoint, which is why that one returns nothing until their purge runs. The
* per-product endpoint this console calls is NOT gated, so the protection has
* to sit here in the meantime.
*
* An unrecognised category is treated as not-food. It reads on screen as "not
* scored yet", which is honest — we genuinely do not know — and is the same
* thing every catalogue-linked product shows today anyway.
*/
export function isEdible(category: string | null | undefined): boolean {
const value = (category ?? '').trim().toLowerCase();
if (!value) return false;
// "General" carries Palmolive soap and P&G alongside anything else the
// scraper could not place. Ambiguous is not good enough for this decision.
if (value === 'general') return false;
return FOOD_CATEGORY_WORDS.some((word) => value.includes(word));
}
export interface Presented {
/** Nothing usable — do not render the panel at all. */
isEmpty: boolean;
/** Known to the service but not scored yet. */
isPending: boolean;
score: number | null;
band: Band | null;
/** Rounded for display. The service sends one decimal; nobody reads it. */
display: string;
good: string[];
cautions: string[];
tags: string[];
allergens: string[];
/**
* How sure the match is, when it is NOT sure enough to state plainly.
*
* Empty when the confidence is good or absent. When set, the panel must show
* it — a nutrition table presented as fact on a 58% match is a claim the data
* does not support, and the allergen list is the part that could actually
* hurt somebody.
*/
caveat: string;
source: { label: string; url: string } | null;
}
/**
* Turn a service record into what the screen should show.
*
* Three outcomes, deliberately distinct: nothing at all, known-but-unscored,
* and scored. Collapsing the middle one into "nothing" would tell a merchant
* their product is unknown when it is simply waiting in a queue.
*/
export function present(score: NutritionScore | null): Presented {
const empty: Presented = {
isEmpty: true,
isPending: false,
score: null,
band: null,
display: '',
good: [],
cautions: [],
tags: [],
allergens: [],
caveat: '',
source: null,
};
if (!score) return empty;
const value = score.health_score;
if (value === null || value === undefined) {
return { ...empty, isEmpty: false, isPending: true };
}
// A score on something inedible is withheld, not shown with a warning. See
// `isEdible` — the live service currently rates insecticide 80/100.
if (!isEdible(score.category)) {
return { ...empty, isEmpty: false, isPending: true };
}
const band = bandFor(value, score.health_band);
const confidence = score.match_confidence;
const caveat =
typeof confidence === 'number' && confidence < LOW_CONFIDENCE
? `Matched to a reference product with ${Math.round(confidence * 100)}% confidence — treat these figures as a guide.`
: '';
return {
isEmpty: false,
isPending: false,
score: value,
band,
display: String(Math.round(value)),
good: clean(score.positive_insights),
cautions: clean(score.nutritional_cautions),
tags: clean(score.diet_tags),
allergens: clean(score.allergens),
caveat,
source: score.source_url
? { label: score.data_source || 'source', url: score.source_url }
: null,
};
}
/** Drops blanks, which the service sends for an empty `ai_summary` and others. */
function clean(list: string[] | undefined): string[] {
return (list ?? []).map((entry) => (entry ?? '').trim()).filter(Boolean);
}
/**
* The per-100g figures worth showing, in the order a label reads them.
*
* Only the ones present: a table of dashes is worse than a shorter table, and
* the service genuinely omits fields — `added_sugar_g` is null on the 5 Star
* record, and `score_breakdown.fields_missing` names what it could not use.
*/
export function facts(score: NutritionScore | null): { label: string; value: string }[] {
if (!score) return [];
const rows: [string, number | null | undefined, string][] = [
['Energy', score.calories_kcal, 'kcal'],
['Protein', score.protein_g, 'g'],
['Carbohydrate', score.carbohydrates_g, 'g'],
['of which sugars', score.total_sugar_g, 'g'],
['Fibre', score.dietary_fiber_g, 'g'],
['Fat', score.total_fat_g, 'g'],
['of which saturates', score.saturated_fat_g, 'g'],
['Sodium', score.sodium_mg, 'mg'],
];
return rows
.filter(([, value]) => typeof value === 'number')
.map(([label, value, unit]) => ({
label,
value: `${trim(value as number)} ${unit}`,
}));
}
/** 12.0 → "12", 6.8 → "6.8". Trailing zeros are noise on a label. */
function trim(value: number): string {
return String(Math.round(value * 10) / 10);
}

View File

@@ -6,34 +6,43 @@ import { Selector } from '@astryxdesign/core/Selector';
import { Text } from '@astryxdesign/core/Text';
import { TextInput } from '@astryxdesign/core/TextInput';
import { VStack } from '@astryxdesign/core/VStack';
import { Monitor, Plus, UserCog, Users } from 'lucide-react';
import { Bike, Monitor, Plus, UserCog, Users } from 'lucide-react';
import type { RiderRosterRow } from '@/api/deliveries';
import type { PosUser, StaffInfo } from '@/api/types';
import { PageHeader } from '@/components/PageHeader';
import { SectionHeader } from '@/components/SectionHeader';
import {
usePosRoles,
usePosUsersByBranch,
useRiderRoster,
useStaff,
useStaffShifts,
} from '@/queries/hooks';
import { useBranchScope } from '../BranchScope';
import { branchLabel } from '../format';
import { PersonDrawer, TillDrawer } from '../PeopleDrawers';
import { RiderDrawer } from '../RiderDrawer';
import { isUnplaced } from '../staffPlacement';
type Group = 'store' | 'till';
type Group = 'store' | 'till' | 'rider';
/**
* Users & access — the two account systems, side by side but never merged.
* Users & access — three account systems, side by side and never merged.
*
* A till account is NOT a Nearle Daily user. The backend excludes roles 7 and 8
* from every application lookup *inside the query itself* — deliberately, so a
* cashier is "not found" rather than "refused". Merging the two into one table
* would list people in a directory they cannot sign in to, and would invite an
* edit that moves someone between two systems that do not share a login.
* cashier is "not found" rather than "refused". A rider is a third kind again:
* they sign in to the rider app, and they are three database rows rather than
* one. Merging any of them into a single table would list people in a directory
* they cannot sign in to, and would invite an edit that moves someone between
* logins that do not exist for each other.
*
* Neither group offers a delete. Both delete endpoints are hard deletes with no
* cascade, and `status` already carries deactivation.
* No group offers a delete. Every delete endpoint here is a hard delete with no
* cascade, and `status` already carries deactivation — most of all for a rider,
* whose name is attached to every delivery they have ever made.
*
* Creating an Administrator is deliberately not possible from this page; see
* `STAFF_ROLES` in `PeopleDrawers`.
*/
export function UsersPage() {
const { branches, scoped, selected, current, tenantid } = useBranchScope();
@@ -44,6 +53,7 @@ export function UsersPage() {
const [editingTill, setEditingTill] = useState<{ row: PosUser | 'new'; locationid: number } | null>(
null,
);
const [editingRider, setEditingRider] = useState<RiderRosterRow | 'new' | null>(null);
const branchIds = useMemo(() => scoped.map((branch) => branch.locationid), [scoped]);
@@ -52,6 +62,27 @@ export function UsersPage() {
const roles = usePosRoles();
const shifts = useStaffShifts(tenantid || undefined, scoped[0]?.locationid);
/**
* The rider directory.
*
* `useRiderRoster`, not `useRiders` — the latter is the ON-DUTY read, which
* requires a clock-in stamped today. Used as a staff list it would hide the
* rider you just added, which reads as a failed save. The roster carries the
* duty state as a field instead, so the list can show it without filtering on
* it.
*/
const riders = useRiderRoster(tenantid || undefined);
const riderRows = useMemo(() => {
const term = keyword.trim().toLowerCase();
return (riders.data ?? []).filter((rider) => {
if (!term) return true;
return `${rider.fullname} ${rider.contactno} ${rider.vehicleno} ${rider.email}`
.toLowerCase()
.includes(term);
});
}, [riders.data, keyword]);
const staffRows = useMemo(() => {
const term = keyword.trim().toLowerCase();
return (staff.data ?? []).filter((person) => {
@@ -112,12 +143,19 @@ export function UsersPage() {
description="Who can sign in to the console, and who can open a till."
actions={
<Button
label={group === 'store' ? 'Add person' : 'Add terminal account'}
label={
group === 'store'
? 'Add person'
: group === 'rider'
? 'Add rider'
: 'Add terminal account'
}
variant="primary"
size="sm"
icon={<Plus size={14} />}
onClick={() => {
if (group === 'store') setEditingPerson('new');
else if (group === 'rider') setEditingRider('new');
else {
const locationid = selected ?? scoped[0]?.locationid;
if (locationid) setEditingTill({ row: 'new', locationid });
@@ -147,26 +185,46 @@ export function UsersPage() {
setRoleFilter('');
}}
/>
{/* A third account system, on the same premise as the other two:
never merged. A rider signs in to the rider app — not the console
and not a till — so listing them together would invite an edit
that moves someone between logins that do not exist for each
other. */}
<TabButton
label="Riders"
icon={<Bike size={14} />}
badge={riderRows.length || undefined}
isActive={group === 'rider'}
onClick={() => {
setGroup('rider');
setRoleFilter('');
}}
/>
</HStack>
}
/>
<VStack gap={1.5}>
<SectionHeader
title={group === 'store' ? 'Back office' : 'Counter staff'}
title={
group === 'store' ? 'Back office' : group === 'rider' ? 'Riders' : 'Counter staff'
}
action={
<HStack gap={1} wrap="wrap">
<div style={{ width: 170 }}>
<Selector
label="Role"
isLabelHidden
size="sm"
value={roleFilter}
onChange={setRoleFilter}
placeholder="All roles"
options={roleOptions}
/>
</div>
{/* Riders have no role to filter by — a rider is a rider. */}
{group !== 'rider' ? (
<div style={{ width: 170 }}>
<Selector
label="Role"
isLabelHidden
size="sm"
value={roleFilter}
onChange={setRoleFilter}
placeholder="All roles"
options={roleOptions}
/>
</div>
) : null}
<div style={{ width: 220 }}>
<TextInput
label="Search people"
@@ -190,6 +248,12 @@ export function UsersPage() {
branches={branches}
onEdit={setEditingPerson}
/>
) : group === 'rider' ? (
<RiderTable
rows={riderRows}
isLoading={riders.isLoading}
onEdit={setEditingRider}
/>
) : (
<TillTable
rows={tillRows}
@@ -219,10 +283,134 @@ export function UsersPage() {
onClose={() => setEditingTill(null)}
/>
) : null}
{editingRider ? (
<RiderDrawer
row={editingRider === 'new' ? null : editingRider}
tenantid={tenantid}
/* The delivery region is inherited from the branch, so the drawer
needs one. A selected branch wins; otherwise the first in scope,
since every branch of a tenant sits in the same region in practice
and the drawer says so when it does not. */
branch={current ?? scoped[0]}
onClose={() => setEditingRider(null)}
/>
) : null}
</VStack>
);
}
/* ── Riders ──────────────────────────────────────────────────────────────── */
/**
* The rider directory.
*
* Duty is shown as a state, never as a filter — somebody hired this morning and
* somebody who simply is not working today are both real riders, and a list
* that hid either would be lying about the fleet. "Off shift" is the ordinary
* case, not a warning.
*/
function RiderTable({
rows,
isLoading,
onEdit,
}: {
rows: RiderRosterRow[];
isLoading: boolean;
onEdit: (row: RiderRosterRow) => void;
}) {
if (isLoading) {
return (
<Card padding={0} elevation="low">
<VStack padding={3}>
<Text type="body" size="sm" color="secondary">
Reading riders…
</Text>
</VStack>
</Card>
);
}
if (rows.length === 0) {
return (
<Card padding={0} elevation="low">
<VStack gap={1} padding={4} align="center">
<span style={{ color: 'var(--color-ink-4)' }}>
<Bike size={22} />
</span>
<Text type="label" size="sm" weight="semibold">
No riders yet
</Text>
<Text type="body" size="sm" color="secondary" style={{ textAlign: 'center', maxWidth: 400 }}>
Add a rider to give them the app and start assigning them deliveries.
</Text>
</VStack>
</Card>
);
}
return (
<Card padding={0} elevation="low">
<div className="table-scroll">
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr>
<Th>Rider</Th>
<Th>Vehicle</Th>
<Th>Shift</Th>
<Th>Partner</Th>
<Th>On shift</Th>
<Th>Status</Th>
<Th />
</tr>
</thead>
<tbody>
{rows.map((rider) => (
<tr key={rider.userid}>
<Td>
<strong style={{ display: 'block' }}>{rider.fullname || `Rider ${rider.userid}`}</strong>
<span style={{ fontSize: 11.5, color: 'var(--color-ink-4)' }}>
{rider.contactno}
</span>
</Td>
<Td>
{rider.vehicleno || rider.vehiclename ? (
<>
<strong style={{ display: 'block' }}>{rider.vehicleno || '—'}</strong>
<span style={{ fontSize: 11.5, color: 'var(--color-ink-4)' }}>
{rider.vehiclename}
</span>
</>
) : (
<span style={{ color: 'var(--color-ink-4)' }}>—</span>
)}
</Td>
<Td isMuted>{rider.shiftname || '—'}</Td>
<Td isMuted>{rider.partnername || '—'}</Td>
<Td>
<Chip
label={rider.isonduty ? 'On shift' : 'Off shift'}
tone={rider.isonduty ? 'success' : 'neutral'}
/>
</Td>
<Td>
<Chip
label={rider.status || 'Active'}
tone={(rider.status ?? 'Active') === 'Active' ? 'success' : 'warning'}
/>
</Td>
<Td>
<Button label="Edit" variant="secondary" size="sm" onClick={() => onEdit(rider)} />
</Td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
);
}
/* ── Tables ──────────────────────────────────────────────────────────────── */
function StaffTable({

View File

@@ -75,3 +75,168 @@
.progress-move:disabled { color: var(--color-ink-4); cursor: not-allowed; }
.progress-move[data-danger='true']:not(:disabled) { color: #b3261e; border-color: #f0cfcd; }
.progress-move:focus-visible { outline: 2px solid var(--color-brand); outline-offset: 2px; }
/* ── Product sizes ──────────────────────────────────────────────────────── */
/* One row per size, showing the three things that decide whether a shopper can
pick it: the label, the price, and the stock. Stock is not decoration here —
a size at zero is dropped from the picker exactly as a lone product would be,
which is the thing merchants assume grouping fixes. */
.size-list {
display: flex;
flex-direction: column;
border: 1px solid var(--color-line);
border-radius: 10px;
overflow: hidden;
}
.size-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
gap: 12px;
align-items: baseline;
padding: 9px 12px;
border-bottom: 1px solid var(--color-line);
}
.size-row:last-child { border-bottom: none; }
/* The product you opened, marked so the list does not read as four unrelated
things when one of them is the card you are standing on. */
.size-row[data-parent='true'] { background: var(--color-brand-tint); }
.size-label {
font: 600 13px/1.3 var(--font-sans);
color: var(--color-ink-1);
display: inline-flex;
align-items: baseline;
gap: 6px;
min-width: 0;
}
.size-label em {
font: 400 10.5px/1 var(--font-sans);
font-style: normal;
color: var(--color-brand);
text-transform: uppercase;
letter-spacing: .06em;
}
.size-price {
font: 600 13px/1.3 var(--font-sans);
color: var(--color-ink-1);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.size-stock {
font: 400 11.5px/1.3 var(--font-sans);
color: var(--color-ink-3);
white-space: nowrap;
}
/* Amber, not red: out of stock is an ordinary state of a shelf, not a fault. */
.size-stock[data-empty='true'] { color: #8a5a00; }
.size-note {
display: flex;
align-items: flex-start;
gap: 8px;
color: var(--color-ink-3);
}
.size-note svg { flex: none; margin-top: 2px; }
.size-add {
padding: 13px;
border: 1px solid var(--color-line);
border-radius: 10px;
background: var(--color-surface-subtle);
}
/* ── Health score ───────────────────────────────────────────────────────── */
/* Green through red, and this is the one place on a product page where a
good/bad colour is the honest reading rather than decoration. Kept clear of
the brand purple so it is never mistaken for a Nearle judgement — the score
comes from the catalogue service, and the source link says so. */
.health-head { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; }
.health-score {
display: inline-flex;
align-items: baseline;
gap: 2px;
padding: 5px 11px;
border-radius: 10px;
font: 700 22px/1 var(--font-sans);
font-variant-numeric: tabular-nums;
}
.health-score em { font: 500 11px/1 var(--font-sans); font-style: normal; opacity: .65; }
.health-band { font: 600 13px/1.3 var(--font-sans); }
.health-points { margin: 0; padding: 0; list-style: none; display: flex; flex-direction: column; gap: 5px; }
.health-points li {
display: grid;
grid-template-columns: 16px minmax(0, 1fr);
gap: 7px;
align-items: start;
font: 400 12.5px/1.45 var(--font-sans);
}
.health-points li svg { margin-top: 2px; }
.health-points[data-tone='good'] li { color: var(--color-ink-2); }
.health-points[data-tone='good'] li svg { color: #1c6b47; }
.health-points[data-tone='caution'] li { color: var(--color-ink-2); }
.health-points[data-tone='caution'] li svg { color: #8a5a00; }
.health-tags { display: flex; gap: 6px; flex-wrap: wrap; }
.health-tag {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 9px;
border-radius: 999px;
background: #eef8f2;
color: #1c6b47;
font: 500 11.5px/1.4 var(--font-sans);
}
/* Its own row, never mixed with the diet tags: one is a selling point, the
other is a warning somebody may be scanning for. */
.health-allergens {
display: flex;
align-items: center;
gap: 8px;
padding: 9px 12px;
border: 1px solid #f2d9a8;
border-radius: 10px;
background: #fdf6e8;
color: #8a5a00;
font: 500 12.5px/1.4 var(--font-sans);
}
.health-allergens svg { flex: none; }
.health-facts {
border: 1px solid var(--color-line);
border-radius: 10px;
overflow: hidden;
}
.health-facts > div {
display: flex;
justify-content: space-between;
gap: 12px;
padding: 7px 12px;
border-bottom: 1px solid var(--color-line);
font: 400 12.5px/1.4 var(--font-sans);
color: var(--color-ink-2);
}
.health-facts > div strong {
color: var(--color-ink-1);
font-weight: 600;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.health-facts p {
margin: 0;
padding: 6px 12px;
background: var(--color-surface-subtle);
font: 400 11px/1.3 var(--font-sans);
color: var(--color-ink-3);
}
.health-source {
display: inline-flex;
align-items: center;
gap: 5px;
font: 500 11.5px/1.3 var(--font-sans);
color: var(--color-ink-3);
text-decoration: none;
}
.health-source:hover { color: var(--color-brand); text-decoration: underline; }

View File

@@ -0,0 +1,157 @@
import { strict as assert } from 'node:assert';
import { test } from 'node:test';
import type { Product } from '@/api/types';
import {
baseName,
groupCandidates,
groupingProblem,
priceOf,
stockOf,
suggestVariantName,
visibleSizes,
} from './productVariants';
/*
Grouping sizes under one product. The fixtures are real rows from Suriya Store
and R mart, verified 4 Sep 2026 — including the awkward ones ("9.8 g" with a
space and a decimal) that a plausible invented fixture would not have.
*/
const product = (over: Partial<Product> = {}): Product => ({
productid: 1,
tenantid: 1135,
productname: 'Cadbury 5 Star 18g',
price: 5,
retailprice: 5,
productstock: 30,
...over,
});
/* ── Reading the size out of a name ──────────────────────────────────────── */
test('the size comes off the end of the name', () => {
assert.equal(suggestVariantName('Aachi Baby Fryums 500g'), '500g');
assert.equal(suggestVariantName('Aachi Baby Fryums 1kg'), '1kg');
assert.equal(suggestVariantName('Cadbury 5 Star 18g'), '18g');
});
test('a decimal size with a space still reads', () => {
// A real row: "Cadbury 5 Star 9.8 g". Both the decimal and the space are in
// the catalogue as typed.
assert.equal(suggestVariantName('Cadbury 5 Star 9.8 g'), '9.8g');
});
test('units are normalised to how a shopper writes them', () => {
assert.equal(suggestVariantName('Rice 5 KG'), '5kg');
assert.equal(suggestVariantName('Oil 1 Ltr'), '1L');
assert.equal(suggestVariantName('Atta 500 gms'), '500g');
assert.equal(suggestVariantName('Eggs 6 pieces'), '6pcs');
});
test('a name with no size gets no label, rather than a wrong one', () => {
/*
The tempting shortcut is "take the last word", which labels
"Cadbury Dairy Milk Silk" as "Silk" and puts that in a size picker.
*/
assert.equal(suggestVariantName('Cadbury Dairy Milk Silk'), '');
assert.equal(suggestVariantName(''), '');
assert.equal(suggestVariantName(undefined), '');
});
test('a number that is part of the brand is not a size', () => {
// Anchored to the end, so the 7 in "7 Up" cannot be read as a quantity.
assert.equal(suggestVariantName('7 Up 250ml'), '250ml');
assert.equal(suggestVariantName('5 Star'), '');
});
/* ── Finding the siblings ────────────────────────────────────────────────── */
test('two sizes of one product share a base name', () => {
assert.equal(baseName('Aachi Baby Fryums 500g'), 'aachi baby fryums');
assert.equal(baseName('Aachi Baby Fryums 1kg'), 'aachi baby fryums');
assert.equal(baseName('Cadbury 5 Star 18g'), baseName('Cadbury 5 Star 9.8 g'));
});
test('likely siblings come first, but nothing is hidden', () => {
/*
Name matching is a heuristic. A shop whose products are named inconsistently
must still be able to find the right one, so the unlikely candidates are
sorted down rather than filtered out.
*/
const parent = product({ productid: 7085, productname: 'Cadbury 5 Star 18g' });
const all = [
parent,
product({ productid: 7084, productname: 'Cadbury 5 Star 9.8 g' }),
product({ productid: 9, productname: 'Dairy Milk 12g' }),
];
const candidates = groupCandidates(parent, all, new Set());
assert.equal(candidates.length, 2, 'the parent itself is never a candidate');
assert.equal(candidates[0]?.product.productid, 7084);
assert.equal(candidates[0]?.isLikely, true);
assert.equal(candidates[0]?.suggestion, '9.8g');
assert.equal(candidates[1]?.isLikely, false);
});
test('products already grouped are not offered again', () => {
const parent = product({ productid: 7085 });
const all = [parent, product({ productid: 7084, productname: 'Cadbury 5 Star 9.8 g' })];
assert.deepEqual(groupCandidates(parent, all, new Set([7084])), []);
});
/* ── Refusals ────────────────────────────────────────────────────────────── */
test('a product cannot be a size of itself', () => {
const p = product({ productid: 7085 });
assert.match(groupingProblem(p, p, new Set()), /itself/i);
});
test('a product already linked is refused', () => {
const parent = product({ productid: 7085 });
const child = product({ productid: 7084 });
assert.match(groupingProblem(parent, child, new Set([7084])), /already/i);
});
test('a product from another shop is refused', () => {
const parent = product({ productid: 7085, tenantid: 1135 });
const child = product({ productid: 7084, tenantid: 1147 });
assert.match(groupingProblem(parent, child, new Set()), /different shop/i);
});
test('an ordinary sibling is allowed', () => {
const parent = product({ productid: 7085 });
const child = product({ productid: 7084 });
assert.equal(groupingProblem(parent, child, new Set()), '');
});
/* ── What the shopper actually sees ──────────────────────────────────────── */
test('a size with no stock is still hidden from the app', () => {
/*
The thing every merchant assumes grouping fixes, and it does not. Suriya
Store's 5 Star is exactly this: 18g has 30 in stock, 9.8g has none, so even
linked the picker shows one size until the small one is restocked.
*/
const sizes = [
{ product: product({ productid: 7085, productstock: 30 }), variantname: '18g' },
{ product: product({ productid: 7084, productstock: 0 }), variantname: '9.8g' },
];
const { shown, hidden } = visibleSizes(sizes);
assert.deepEqual(shown.map((s) => s.variantname), ['18g']);
assert.deepEqual(hidden.map((s) => s.variantname), ['9.8g']);
});
test('a size with no price is hidden too', () => {
// Same gate as a lone product: an unpriced item is withheld from the app.
const sizes = [
{ product: product({ productstock: 10, price: 0, retailprice: 0 }), variantname: '18g' },
];
assert.equal(visibleSizes(sizes).shown.length, 0);
});
test('price falls back from the branch price to the retail price', () => {
assert.equal(priceOf(product({ price: 12, retailprice: 20 })), 12);
assert.equal(priceOf(product({ price: 0, retailprice: 20 })), 20);
assert.equal(priceOf(product({ price: 0, retailprice: 0 })), 0);
assert.equal(stockOf(product({ productstock: 7 })), 7);
});

View File

@@ -0,0 +1,172 @@
import type { Product } from '@/api/types';
/**
* Grouping sizes of the same product under one card.
*
* A shop stocks "Cadbury 5 Star 18g" and "Cadbury 5 Star 9.8g" as two products
* — two barcodes, two stock counts, two prices — and that is correct for the
* shop. It is wrong for the shopper, who sees the same chocolate twice and has
* to work out that one is smaller.
*
* So the products stay separate and a LINK is added: one becomes the parent the
* app shows, the others become sizes under it. Nothing is merged and no stock
* moves; the app just asks for the parent and gets a size picker.
*
* Pure, so the rules below can be tested without a shop.
*/
/**
* The size label, guessed from the product name.
*
* "Aachi Baby Fryums 500g" → "500g". A picker showing three buttons should read
* 100g / 500g / 1kg, not the brand name three times over — but the guess is
* only ever a SUGGESTION the merchant can overwrite, because product names are
* not a format anybody promised to keep.
*
* Returns an empty string when nothing looks like a size, rather than guessing
* at the last word — "Cadbury Dairy Milk Silk" would otherwise be labelled
* "Silk".
*/
export function suggestVariantName(productname: string | undefined): string {
const name = (productname ?? '').trim();
if (!name) return '';
/*
* Number, optional space, unit, at the END of the name.
*
* Anchored to the end because that is where a pack size sits, and unanchored
* it matches the wrong thing: "7 Up 250ml" would give "7 Up". The decimal is
* for real cases like "5 Star 9.8 g", which is a product this catalogue
* actually carries.
*/
const match = name.match(/(\d+(?:\.\d+)?)\s*(kg|g|gm|gms|grams?|ml|l|ltr|litres?|liters?|pcs?|pieces?|pack)\b\.?$/i);
if (!match) return '';
const amount = match[1] ?? "";
const unit = match[2] ?? "";
return `${amount}${normaliseUnit(unit)}`;
}
/** Units as a shopper writes them, not as a spreadsheet does. */
function normaliseUnit(unit: string): string {
const u = unit.toLowerCase().replace(/\.$/, '');
if (u === 'gm' || u === 'gms' || u === 'gram' || u === 'grams') return 'g';
if (u === 'ltr' || u === 'litre' || u === 'litres' || u === 'liter' || u === 'liters') return 'L';
if (u === 'l') return 'L';
if (u === 'piece' || u === 'pieces' || u === 'pc') return 'pcs';
return u;
}
/**
* The part of a name that is NOT the size.
*
* Used to find likely siblings: two products whose names agree once their pack
* sizes are removed are almost certainly the same thing in different weights.
*/
export function baseName(productname: string | undefined): string {
const name = (productname ?? '').trim();
const size = suggestVariantName(name);
if (!size) return name.toLowerCase();
return name
.replace(/(\d+(?:\.\d+)?)\s*(kg|g|gm|gms|grams?|ml|l|ltr|litres?|liters?|pcs?|pieces?|pack)\b\.?$/i, '')
.trim()
.toLowerCase();
}
export interface GroupCandidate {
product: Product;
/** The size label to offer, pre-filled. */
suggestion: string;
/** True when the name matches the parent's once sizes are stripped. */
isLikely: boolean;
}
/**
* Products that could be sizes of this one.
*
* Everything in the shop except the parent itself and anything already grouped,
* ordered so the likely siblings come first. Deliberately NOT filtered down to
* the likely ones: name matching is a heuristic, and a merchant whose products
* are named inconsistently must still be able to find the right one rather than
* being told it does not exist.
*/
export function groupCandidates(
parent: Product,
all: readonly Product[],
alreadyLinked: ReadonlySet<number>,
): GroupCandidate[] {
const parentBase = baseName(parent.productname);
return all
.filter((product) => product.productid !== parent.productid)
.filter((product) => !alreadyLinked.has(product.productid))
.map((product) => ({
product,
suggestion: suggestVariantName(product.productname),
isLikely: parentBase !== '' && baseName(product.productname) === parentBase,
}))
.sort((a, b) => {
if (a.isLikely !== b.isLikely) return a.isLikely ? -1 : 1;
return (a.product.productname ?? '').localeCompare(b.product.productname ?? '');
});
}
/**
* Why a product cannot be a size of this one.
*
* Empty string when it can. The backend enforces all of this too — it refuses a
* self-reference, a cross-tenant link and a duplicate — but a form that lets
* you press a button and then explains why it could never have worked is worse
* than one that never offered the button.
*/
export function groupingProblem(
parent: Product,
child: Product,
alreadyLinked: ReadonlySet<number>,
): string {
if (parent.productid === child.productid) {
return 'A product cannot be a size of itself.';
}
if (alreadyLinked.has(child.productid)) {
return 'This is already one of the sizes.';
}
if (parent.tenantid && child.tenantid && parent.tenantid !== child.tenantid) {
return 'That product belongs to a different shop.';
}
return '';
}
/**
* What the shopper will actually see, given the sizes chosen.
*
* A size with no stock is dropped by the app the same way a lone product is, so
* grouping does not rescue an out-of-stock item — a point worth making on the
* screen, because it is the first thing a merchant assumes grouping fixes.
* Cadbury 5 Star is exactly this case: 18g has 30 in stock, 9.8g has none, and
* linking them changes nothing until the small one is restocked.
*/
export function visibleSizes(
sizes: readonly { product: Product; variantname: string }[],
): { shown: typeof sizes; hidden: typeof sizes } {
const shown = sizes.filter((size) => stockOf(size.product) > 0 && priceOf(size.product) > 0);
const hidden = sizes.filter((size) => stockOf(size.product) <= 0 || priceOf(size.product) <= 0);
return { shown, hidden };
}
/**
* Stock as the app counts it.
*
* `productstock` is the field the product reads carry; the stock STATEMENT has
* its own `closing`, which is a different shape and not what arrives here.
*/
export function stockOf(product: Product): number {
return product.productstock ?? 0;
}
/**
* The price the app would charge, in the order the backend resolves it: the
* branch's own price first, the product's retail price behind it.
*/
export function priceOf(product: Product): number {
return product.price || product.retailprice || 0;
}

View File

@@ -10,7 +10,7 @@
import { useQueries, useQuery, type UseQueryOptions } from '@tanstack/react-query';
import { catalogueApi, type CatalogueQuery } from '@/api/catalogue';
import { deliveriesApi } from '@/api/deliveries';
import { deliveriesApi, ridersApi } from '@/api/deliveries';
import { insightsApi, type DateRange, type OrderQuery } from '@/api/insights';
import { productsApi } from '@/api/products';
import { posUsersApi, staffApi } from '@/api/people';
@@ -449,3 +449,39 @@ export function useUploads(tenantid: number | undefined, locationid?: number) {
...stable,
});
}
/**
* The rider directory — everyone, whether or not they are working today.
*
* `stable`, not `live`, and that is the difference from `useRiders`. This is a
* staff list: it changes when somebody is hired or edited, not through the day
* as shifts start and end. The on-duty read is the one that has to keep asking.
*/
export function useRiderRoster(tenantid: number | undefined) {
return useQuery({
queryKey: queryKeys.insights.riderRoster(tenantid ?? 0),
queryFn: () => ridersApi.roster(tenantid as number),
enabled: Boolean(tenantid),
...stable,
});
}
/** Shifts a rider can be put on. Scoped by region; the param is required. */
export function useRiderShifts(applocationid: number | undefined) {
return useQuery({
queryKey: queryKeys.insights.riderShifts(applocationid ?? 0),
queryFn: () => ridersApi.shifts(applocationid as number),
enabled: Boolean(applocationid),
...stable,
});
}
/** Delivery partners in a region. */
export function usePartners(applocationid: number | undefined) {
return useQuery({
queryKey: queryKeys.insights.partners(applocationid ?? 0),
queryFn: () => ridersApi.partners(applocationid as number),
enabled: Boolean(applocationid),
...stable,
});
}

View File

@@ -73,6 +73,13 @@ export const queryKeys = {
/** Keyed on the delivery region, which is what the endpoint filters on. */
riders: (applocationid: number) =>
[...queryKeys.insights.all, 'riders', applocationid] as const,
/** The directory — everyone, working today or not. */
riderRoster: (tenantid: number) =>
[...queryKeys.insights.all, 'rider-roster', tenantid] as const,
riderShifts: (applocationid: number) =>
[...queryKeys.insights.all, 'rider-shifts', applocationid] as const,
partners: (applocationid: number) =>
[...queryKeys.insights.all, 'partners', applocationid] as const,
},
people: {