diff --git a/src/api/deliveries.ts b/src/api/deliveries.ts index cb5ccf3..741a45a 100644 --- a/src/api/deliveries.ts +++ b/src/api/deliveries.ts @@ -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(`${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(`${WEB}/partners/updaterider`, rider), + + /** Shifts to choose from. Scoped by region, and the param is required. */ + shifts: (applocationid: number) => + api.list(`${WEB}/partners/getridershifts`, { applocationid }), + + /** Delivery partners a rider can ride for. */ + partners: (applocationid: number) => + api.list(`${WEB}/partners/getpartners`, { applocationid }), +}; diff --git a/src/api/nutrition.ts b/src/api/nutrition.ts new file mode 100644 index 0000000..a5a142f --- /dev/null +++ b/src/api/nutrition.ts @@ -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(path: string): Promise { + 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( + `/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); +} diff --git a/src/api/products.ts b/src/api/products.ts index 79cca6a..aec0864 100644 --- a/src/api/products.ts +++ b/src/api/products.ts @@ -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(`${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(`${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(`${MOB}/products/getproductbyvariant`, params), +}; diff --git a/src/api/types.ts b/src/api/types.ts index d30cbf8..c9c77a1 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -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; diff --git a/src/features/store-admin/HealthScorePanel.tsx b/src/features/store-admin/HealthScorePanel.tsx new file mode 100644 index 0000000..2bee114 --- /dev/null +++ b/src/features/store-admin/HealthScorePanel.tsx @@ -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 ( + + + ); + } + + const shown = present(query.data ?? null); + + if (shown.isEmpty) return null; + + if (shown.isPending) { + return ( + + + ); + } + + const colour = shown.band ? BAND_COLOR[shown.band] : 'var(--color-ink-3)'; + + return ( + + + ); +} + +function Label() { + return ( + + Health score + + ); +} diff --git a/src/features/store-admin/PeopleDrawers.tsx b/src/features/store-admin/PeopleDrawers.tsx index b2907d4..e90652c 100644 --- a/src/features/store-admin/PeopleDrawers.tsx +++ b/src/features/store-admin/PeopleDrawers.tsx @@ -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, diff --git a/src/features/store-admin/ProductDrawer.tsx b/src/features/store-admin/ProductDrawer.tsx index c9df295..ff8b273 100644 --- a/src/features/store-admin/ProductDrawer.tsx +++ b/src/features/store-admin/ProductDrawer.tsx @@ -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({ ) : 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 ? ( + + ) : 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. */} + + {/* ── In your shops ─────────────────────────────────────────────── */} diff --git a/src/features/store-admin/ProductSizes.tsx b/src/features/store-admin/ProductSizes.tsx new file mode 100644 index 0000000..cebd56b --- /dev/null +++ b/src/features/store-admin/ProductSizes.tsx @@ -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(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 ( + + + + Sizes + + {canManage && !adding ? ( +