rider creation
This commit is contained in:
@@ -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
118
src/api/nutrition.ts
Normal 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);
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
176
src/features/store-admin/HealthScorePanel.tsx
Normal file
176
src/features/store-admin/HealthScorePanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
246
src/features/store-admin/ProductSizes.tsx
Normal file
246
src/features/store-admin/ProductSizes.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
258
src/features/store-admin/RiderDrawer.tsx
Normal file
258
src/features/store-admin/RiderDrawer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
229
src/features/store-admin/healthScore.test.ts
Normal file
229
src/features/store-admin/healthScore.test.ts
Normal 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);
|
||||
});
|
||||
241
src/features/store-admin/healthScore.ts
Normal file
241
src/features/store-admin/healthScore.ts
Normal 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);
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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; }
|
||||
|
||||
157
src/features/store-admin/productVariants.test.ts
Normal file
157
src/features/store-admin/productVariants.test.ts
Normal 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);
|
||||
});
|
||||
172
src/features/store-admin/productVariants.ts
Normal file
172
src/features/store-admin/productVariants.ts
Normal 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;
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user