backend integration started

This commit is contained in:
2026-08-26 18:37:28 +05:30
parent 517a99d577
commit 3545819be8
50 changed files with 3461 additions and 3241 deletions

View File

@@ -1,280 +0,0 @@
import { useEffect, useMemo, 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 { Check, DownloadCloud, Info } from 'lucide-react';
import { catalogueKey } from '@/api/catalogue';
import { productsApi } from '@/api/products';
import type { CatalogueProduct, ImportCatalogueProductRequest } from '@/api/types';
import { queryKeys } from '@/queries/keys';
import {
useCatalogueBrands,
useCatalogueProducts,
useImportedRefs,
useTenantCategories,
} from '@/queries/hooks';
import { Drawer } from './Drawer';
import { MediaPill, ProductCard } from './ProductCard';
/**
* Import from the global catalogue, without leaving Inventory.
*
* A drawer rather than a page because importing is one move inside the pricing
* job, not a destination: you come here because a product is missing from the
* list behind, and you want to go straight back to it.
*
* Two things follow the old console because it had them right, and one
* deliberately does not:
*
* - **One click imports.** No confirmation. Import is reversible in the sense
* that matters — the product lands unpriced and unreleased, so nothing
* reaches a till until someone prices it.
* - **The "Imported" badge is keyed on `brand:catalogueid`.** The old console
* compared a catalogue id against tenant PRODUCT ids, which are different
* number spaces, so its badge was effectively random. `getimportedcatalogueproducts`
* returns exactly the right pairs and it fetched them two lines above without
* ever reading them.
*/
export function CatalogueImportDrawer({
tenantid,
locationid,
onClose,
}: {
tenantid: number;
locationid: number;
onClose: () => void;
}) {
const client = useQueryClient();
const [brand, setBrand] = useState('');
const [keyword, setKeyword] = useState('');
const [debounced, setDebounced] = useState('');
const [categoryid, setCategoryid] = useState('');
const [busy, setBusy] = useState<string | null>(null);
const [justImported, setJustImported] = useState<Set<string>>(new Set());
useEffect(() => {
const id = setTimeout(() => setDebounced(keyword.trim()), 400);
return () => clearTimeout(id);
}, [keyword]);
const brands = useCatalogueBrands();
const categories = useTenantCategories(tenantid);
const imported = useImportedRefs(tenantid);
const products = useCatalogueProducts({
...(brand ? { brand } : {}),
...(debounced ? { keyword: debounced } : {}),
pagesize: 48,
});
const importedKeys = useMemo(() => {
const set = new Set((imported.data ?? []).map(catalogueKey));
for (const key of justImported) set.add(key);
return set;
}, [imported.data, justImported]);
const importOne = useMutation({
mutationFn: (row: ImportCatalogueProductRequest) => productsApi.importFromCatalogue([row]),
onSuccess: async () => {
await Promise.all([
client.invalidateQueries({ queryKey: queryKeys.catalogue.all }),
client.invalidateQueries({ queryKey: queryKeys.products.all }),
]);
},
});
async function run(product: CatalogueProduct) {
const key = catalogueKey(product);
setBusy(key);
try {
await importOne.mutateAsync({
tenantid,
locationid,
brand: product.brand,
catalogueid: product.id,
// Uncategorised beats wrongly categorised. The backend makes this field
// optional for exactly that reason: "an unclassified product is visibly
// unfinished, while a wrongly classified one looks done and is only
// found by someone browsing the wrong aisle."
categoryid: Number(categoryid) || 0,
subcategoryid: 0,
quantity: 0,
stocktype: 'in',
status: 'Draft',
// Zero on purpose. Import is not pricing — the product lands in the
// "Not ready" list behind this drawer, which is where a price is set.
retailprice: 0,
productcost: 0,
taxpercent: 0,
});
setJustImported((set) => new Set(set).add(key));
} finally {
setBusy(null);
}
}
const brandOptions = useMemo(
() => [
{ value: '', label: 'All brands' },
...(brands.data ?? []).map((entry) => ({
value: entry.brand,
label: `${entry.brand.replace('brand_', '').toUpperCase()} (${entry.product_count})`,
})),
],
[brands.data],
);
const categoryOptions = useMemo(
() => [
{ value: '', label: 'Leave uncategorised' },
...(categories.data ?? []).map((entry) => ({
value: String(entry.categoryid),
label: entry.categoryname,
})),
],
[categories.data],
);
const rows = products.data ?? [];
return (
<Drawer
title="Add products"
subtitle="Pick what you sell from the global catalogue. Prices are set afterwards."
width={920}
isBare
onClose={onClose}
>
<VStack gap={1.5} padding={2} style={{ flex: 'none' }}>
<HStack gap={1} wrap="wrap">
<div style={{ width: 200 }}>
<Selector
label="Brand"
isLabelHidden
size="sm"
value={brand}
onChange={setBrand}
placeholder="All brands"
options={brandOptions}
/>
</div>
<div style={{ width: 220 }}>
<TextInput
label="Search the catalogue"
isLabelHidden
size="sm"
value={keyword}
onChange={setKeyword}
placeholder="Search the catalogue…"
hasClear
/>
</div>
<div style={{ width: 210 }}>
<Selector
label="Category to import into"
isLabelHidden
size="sm"
value={categoryid}
onChange={setCategoryid}
placeholder="Leave uncategorised"
options={categoryOptions}
/>
</div>
</HStack>
<HStack gap={1} align="start">
<span style={{ color: 'var(--color-ink-4)', flex: 'none', marginTop: 1 }}>
<Info size={13} />
</span>
<Text type="body" size="xsm" color="secondary" style={{ lineHeight: 1.55 }}>
Imported products arrive with no price and are not released to your shops. They
appear under <strong>Not ready</strong> behind this panel, where you set the price.
</Text>
</HStack>
</VStack>
<div className="import-scroll" style={{ padding: 16, paddingTop: 0 }}>
{products.isLoading ? (
<Text type="body" size="sm" color="secondary">
Loading the catalogue…
</Text>
) : rows.length === 0 ? (
<VStack gap={0.5}>
<Text type="label" size="sm" weight="semibold">
No products match your selection
</Text>
<Text type="body" size="sm" color="secondary">
Try a different brand, or clear the search.
</Text>
</VStack>
) : (
<div className="product-grid">
{rows.map((product) => {
const key = catalogueKey(product);
const isImported = importedKeys.has(key);
const images = product.images ?? [];
return (
<ProductCard
key={key}
images={images}
title={product.product_name}
identifier={product.product_sku ?? `ID: ${product.id}`}
left={product.size || '—'}
right={product.price_range || '—'}
mediaBadge={
<MediaPill label={product.brand.replace('brand_', '')} color="var(--color-brand)" />
}
{...(isImported
? {
mediaStatus: (
<MediaPill label="In your list" color="#10b981" isSolid />
),
}
: {})}
action={
isImported ? (
<Button
label="Added"
variant="secondary"
size="sm"
icon={<Check size={12} />}
isDisabled
width="full"
/>
) : (
<Button
label={busy === key ? 'Adding…' : 'Add to my products'}
variant="primary"
size="sm"
icon={<DownloadCloud size={12} />}
isDisabled={busy !== null}
width="full"
onClick={() => void run(product)}
/>
)
}
/>
);
})}
</div>
)}
{/* The photo count the import silently drops. 90 of Nestlé's 123
products carry more than one image and only the first survives. */}
{rows.some((product) => (product.images?.length ?? 0) > 1) ? (
<Text
type="body"
size="xsm"
style={{ color: 'var(--color-ink-4)', display: 'block', paddingTop: 12 }}
>
Products with several photos import only the first one.
</Text>
) : null}
</div>
</Drawer>
);
}

View File

@@ -0,0 +1,53 @@
import { useMemo } from 'react';
import { useTenantCategories } from '@/queries/hooks';
import { CatalogueBrowser } from '@/features/catalogue/CatalogueBrowser';
import { useBranchScope } from './BranchScope';
/**
* The Store Admin's half of the catalogue: browse it, add to your own products.
*
* A thin wrapper over the shared browser, and deliberately thin. What is
* particular to this workspace is only the scope — the merchant is whoever is
* signed in — and one click imports, because a merchant adding a product to
* their own list has no pricing decision to make yet: the product lands under
* "Not ready" and is priced there.
*/
export function CataloguePanel() {
const { tenantid, current, branches } = useBranchScope();
/**
* Import writes against one outlet, which the backend requires.
*
* With "All branches" selected there is no single destination, so the first
* is used — and that is not a fudge: `PublishProduct` releases tenant-wide,
* so the outlet on the import row decides where the product row is filed,
* not who can sell it.
*/
const locationid = current?.locationid ?? branches[0]?.locationid;
const categories = useTenantCategories(tenantid);
const categoryOptions = useMemo(
() => [
{ value: '', label: 'Leave uncategorised' },
...(categories.data ?? []).map((entry) => ({
value: String(entry.categoryid),
label: entry.categoryname,
})),
],
[categories.data],
);
return (
<CatalogueBrowser
tenantid={tenantid || undefined}
locationid={locationid}
categoryOptions={categoryOptions}
actionLabel="Add to my products"
alignWithTabs
{...(!tenantid || !locationid
? { blockedReason: 'No outlet is selected, so there is nowhere to file an import.' }
: {})}
/>
);
}

View File

@@ -1,369 +0,0 @@
import { useState } from 'react';
import { ChevronLeft, ChevronRight, ImageOff } from 'lucide-react';
/**
* The product card, matched to the old console's.
*
* The proportions and treatment are lifted deliberately rather than reinvented,
* because staff already read them: a **160px white media band** with the image
* inset 12px and `object-contain`, a two-line bold name, the SKU in mono
* underneath, a hairline, then one line of meta with a figure right-aligned,
* then a single full-width action.
*
* `mix-blend-multiply` is the detail that makes it look right and is easy to
* miss. Catalogue photography is shot on white, so without it every card
* carries a visible grey rectangle where the photo's background meets the card.
*/
export interface ProductCardProps {
images: string[];
title: string;
/** The SKU or catalogue id — mono, under the title. */
identifier?: string;
/** Left-hand meta, e.g. "Unit: 500g". */
left?: React.ReactNode;
/** Right-hand figure, e.g. a price. Mono and bold. */
right?: React.ReactNode;
/**
* One short line under the SKU — why this product cannot be sold.
*
* A pill can say "Not ready"; only a sentence can say which of the two
* things is missing, and that is the whole difference between a card the
* merchant can act on and one that just worries them.
*/
note?: string;
noteColor?: string;
/** A badge pinned to the top-left of the image. */
mediaBadge?: React.ReactNode;
/** A badge pinned to the top-right of the image. */
mediaStatus?: React.ReactNode;
action?: React.ReactNode;
onClick?: () => void;
}
export function ProductCard({
images,
title,
identifier,
note,
noteColor,
left,
right,
mediaBadge,
mediaStatus,
action,
onClick,
}: ProductCardProps) {
const [isHovered, setIsHovered] = useState(false);
return (
<div
onClick={onClick}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
style={{
display: 'flex',
flexDirection: 'column',
minWidth: 0,
borderRadius: 16,
border: `1px solid ${isHovered ? 'color-mix(in oklab, var(--color-brand) 40%, transparent)' : 'var(--color-line)'}`,
background: 'var(--color-surface)',
overflow: 'hidden',
cursor: onClick ? 'pointer' : 'default',
boxShadow: isHovered
? '0 8px 20px -6px rgb(16 24 40 / .12)'
: '0 1px 2px rgb(16 24 40 / .05)',
transition: 'box-shadow .2s, border-color .2s',
}}
>
<Media images={images} alt={title} isHovered={isHovered} badge={mediaBadge} status={mediaStatus} />
<div
style={{
display: 'flex',
flexDirection: 'column',
flex: 1,
gap: 8,
padding: 12,
minWidth: 0,
}}
>
<div style={{ flex: 1, minWidth: 0 }}>
<h4
title={title}
style={{
margin: 0,
fontSize: 14,
fontWeight: 700,
lineHeight: 1.35,
color: isHovered ? 'var(--color-brand)' : 'var(--color-ink-1)',
transition: 'color .2s',
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
}}
>
{title}
</h4>
{identifier ? (
<p
style={{
margin: '2px 0 0',
fontSize: 12,
fontWeight: 600,
fontFamily: 'var(--font-mono)',
letterSpacing: '-.01em',
color: 'var(--color-ink-4)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{identifier}
</p>
) : null}
{note ? (
<p
style={{
margin: '4px 0 0',
fontSize: 11.5,
lineHeight: 1.45,
color: noteColor ?? 'var(--color-ink-4)',
}}
>
{note}
</p>
) : null}
</div>
<div
style={{
marginTop: 'auto',
paddingTop: 8,
borderTop: '1px solid var(--color-line)',
display: 'flex',
flexDirection: 'column',
gap: 8,
}}
>
{left !== undefined || right !== undefined ? (
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 8,
fontSize: 12,
color: 'var(--color-ink-3)',
}}
>
<span style={{ minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{left}
</span>
<span
style={{
flex: 'none',
fontWeight: 700,
fontFamily: 'var(--font-mono)',
color: 'var(--color-ink-1)',
}}
>
{right}
</span>
</div>
) : null}
{action}
</div>
</div>
</div>
);
}
/**
* The 160px media band.
*
* Arrows and dots appear only when there is more than one image, and only on
* hover — and every handler stops propagation, or paging through photos would
* open the card instead.
*/
function Media({
images,
alt,
isHovered,
badge,
status,
}: {
images: string[];
alt: string;
isHovered: boolean;
badge?: React.ReactNode;
status?: React.ReactNode;
}) {
const [index, setIndex] = useState(0);
const [failed, setFailed] = useState(false);
const current = images[Math.min(index, images.length - 1)];
const hasMany = images.length > 1;
return (
<div
style={{
position: 'relative',
width: '100%',
height: 160,
flex: 'none',
padding: 12,
display: 'grid',
placeItems: 'center',
background: 'var(--color-surface)',
borderBottom: '1px solid var(--color-line)',
overflow: 'hidden',
}}
>
{current && !failed ? (
<img
src={current}
alt={alt}
loading="lazy"
referrerPolicy="no-referrer"
onError={() => setFailed(true)}
style={{
width: '100%',
height: '100%',
objectFit: 'contain',
// Catalogue shots are on white; without this the photo's ground
// shows as a grey rectangle inside the card.
mixBlendMode: 'multiply',
transform: isHovered ? 'scale(1.08)' : 'scale(1)',
transition: 'transform .7s ease-in-out',
}}
/>
) : (
<span style={{ color: 'var(--color-line)' }}>
<ImageOff size={44} />
</span>
)}
{badge ? <span style={{ position: 'absolute', top: 8, left: 8, zIndex: 2 }}>{badge}</span> : null}
{status ? <span style={{ position: 'absolute', top: 8, right: 8, zIndex: 2 }}>{status}</span> : null}
{hasMany ? (
<>
<Arrow
side="left"
isVisible={isHovered}
onClick={(event) => {
event.stopPropagation();
setFailed(false);
setIndex((value) => (value - 1 + images.length) % images.length);
}}
/>
<Arrow
side="right"
isVisible={isHovered}
onClick={(event) => {
event.stopPropagation();
setFailed(false);
setIndex((value) => (value + 1) % images.length);
}}
/>
<span
style={{
position: 'absolute',
bottom: 6,
left: '50%',
transform: 'translateX(-50%)',
display: 'flex',
gap: 3,
zIndex: 2,
}}
>
{images.slice(0, 8).map((url, position) => (
<span
key={url}
style={{
width: position === index ? 12 : 5,
height: 5,
borderRadius: 999,
background:
position === index ? 'var(--color-brand)' : 'var(--color-line)',
transition: 'width .2s',
}}
/>
))}
</span>
</>
) : null}
</div>
);
}
function Arrow({
side,
isVisible,
onClick,
}: {
side: 'left' | 'right';
isVisible: boolean;
onClick: (event: React.MouseEvent) => void;
}) {
return (
<button
type="button"
aria-label={side === 'left' ? 'Previous image' : 'Next image'}
onClick={onClick}
style={{
position: 'absolute',
[side]: 4,
top: '50%',
transform: 'translateY(-50%)',
zIndex: 2,
width: 26,
height: 26,
display: 'grid',
placeItems: 'center',
borderRadius: 999,
border: '1px solid var(--color-line)',
background: 'var(--color-surface)',
color: 'var(--color-ink-3)',
cursor: 'pointer',
opacity: isVisible ? 1 : 0,
transition: 'opacity .2s',
}}
>
{side === 'left' ? <ChevronLeft size={14} /> : <ChevronRight size={14} />}
</button>
);
}
/** The small uppercase pill the old console pins over a product image. */
export function MediaPill({
label,
color,
isSolid,
}: {
label: string;
color: string;
isSolid?: boolean;
}) {
return (
<span
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 4,
padding: '2px 8px',
borderRadius: 999,
fontSize: 10.5,
fontWeight: 800,
letterSpacing: '.06em',
textTransform: 'uppercase',
whiteSpace: 'nowrap',
background: isSolid ? color : `color-mix(in srgb, ${color} 12%, var(--color-surface))`,
color: isSolid ? '#fff' : color,
border: isSolid ? 'none' : `1px solid color-mix(in srgb, ${color} 35%, transparent)`,
}}
>
{label}
</span>
);
}

View File

@@ -10,7 +10,6 @@ import type { Product } from '@/api/types';
import { SectionHeader } from '@/components/SectionHeader';
import { useLocationProducts, useTenantCategories } from '@/queries/hooks';
import { useBranchScope } from './BranchScope';
import { CatalogueImportDrawer } from './CatalogueImportDrawer';
import { count, money } from './format';
import { ProductDrawer } from './ProductDrawer';
import { PublishDialog } from './PublishDialog';
@@ -40,14 +39,10 @@ import {
* the truth on its own. They are shown per row, not as a filter.
*/
export function ProductsPanel({
isImporting,
onCloseImport,
isUploading,
onCloseUpload,
}: {
/** Owned by InventoryPage, so its buttons can sit in the tab row. */
isImporting: boolean;
onCloseImport: () => void;
/** Owned by InventoryPage, so its button can sit in the tab row. */
isUploading: boolean;
onCloseUpload: () => void;
}) {
@@ -132,13 +127,6 @@ export function ProductsPanel({
onClose={onCloseUpload}
/>
) : null}
{isImporting && tenantid && branch ? (
<CatalogueImportDrawer
tenantid={tenantid}
locationid={branch.locationid}
onClose={onCloseImport}
/>
) : null}
{publishing && tenantid ? (
<PublishDialog
products={publishing}

View File

@@ -30,10 +30,11 @@ import { SectionHeader } from '@/components/SectionHeader';
import { queryKeys } from '@/queries/keys';
import { useStockRequests, useStockStatement } from '@/queries/hooks';
import { useBranchScope } from '../BranchScope';
import { CataloguePanel } from '../CataloguePanel';
import { ProductsPanel } from '../ProductsPanel';
import { branchLabel, count, money } from '../format';
type Tab = 'products' | 'stock' | 'requests';
type Tab = 'products' | 'catalogue' | 'stock' | 'requests';
/**
* Inventory — catalogue, stock and the approval queue.
@@ -55,7 +56,6 @@ export function InventoryPage() {
const [tab, setTab] = useState<Tab>('products');
// Owned here so the button can live in the tab row while the drawer it opens
// stays inside the panel that uses it.
const [isImporting, setImporting] = useState(false);
const [isUploading, setUploading] = useState(false);
const requests = useStockRequests(
@@ -73,9 +73,14 @@ export function InventoryPage() {
title="Inventory"
count={selected === null ? `${branches.length} branches` : current?.locationname}
description="What you sell, where it is stocked, and what the shops are asking you for."
/* The two product actions live here, not in the tab row, so their
position is fixed — they no longer appear and disappear as the tab
changes, and nothing below shifts sideways when they do.
/* Upload sheet lives here, not in the tab row, so its position is
fixed — it no longer appears and disappears as the tab changes, and
nothing below shifts sideways when it does.
"Import products" used to sit beside it and is gone: once browsing
the catalogue became a tab, the button was a second door to a room
already on the wall, and it read as a different action than the tab
it opened.
The "N awaiting approval" badge that used to sit here is gone: the
Stock requests tab already carries the same count, and a number
@@ -89,12 +94,6 @@ export function InventoryPage() {
isDisabled={!tenantid}
onClick={() => setUploading(true)}
/>
<BarAction
label="Import products"
icon={<DownloadCloud size={14} />}
isDisabled={!tenantid}
onClick={() => setImporting(true)}
/>
</HStack>
}
tabs={
@@ -102,13 +101,19 @@ export function InventoryPage() {
giving it a row of its own below. It only appears on that tab —
an action for a panel you are not looking at is a misfire waiting
to happen. */
<HStack gap={0.5} wrap="wrap" style={{ paddingTop: 12 }}>
<HStack gap={0.5} wrap="wrap">
<TabButton
label="Products"
icon={<PackageSearch size={14} />}
isActive={tab === 'products'}
onClick={() => setTab('products')}
/>
<TabButton
label="Catalogue"
icon={<DownloadCloud size={14} />}
isActive={tab === 'catalogue'}
onClick={() => setTab('catalogue')}
/>
<TabButton
label="Stock requests"
icon={<ClipboardList size={14} />}
@@ -128,11 +133,11 @@ export function InventoryPage() {
{tab === 'products' ? (
<ProductsPanel
isImporting={isImporting}
onCloseImport={() => setImporting(false)}
isUploading={isUploading}
onCloseUpload={() => setUploading(false)}
/>
) : tab === 'catalogue' ? (
<CataloguePanel />
) : tab === 'requests' ? (
<RequestsPanel requests={requests.data ?? []} isLoading={requests.isLoading} />
) : (

View File

@@ -55,8 +55,15 @@ const EMPTY: FormState = {
contactno: '',
address: '',
suburb: '',
city: 'Coimbatore',
state: 'Tamil Nadu',
// Empty, not pre-filled.
//
// These carried 'Coimbatore' and 'Tamil Nadu' as VALUES, not placeholders —
// so a merchant anywhere else was submitted with the wrong city and state
// unless somebody noticed and cleared them. A default that is right most of
// the time is worse than a blank field, because it is only checked when it
// looks wrong.
city: '',
state: '',
postcode: '',
opentime: isoTime('08:00'),
closetime: isoTime('22:00'),

View File

@@ -110,7 +110,7 @@ export function ReportsPage() {
/>
}
tabs={
<HStack gap={0.5} wrap="wrap" style={{ paddingTop: 12 }}>
<HStack gap={0.5} wrap="wrap">
<TabButton
label="Revenue"
icon={<IndianRupee size={14} />}

View File

@@ -184,7 +184,7 @@ export function SalesPage() {
</HStack>
}
tabs={
<HStack gap={0.5} wrap="wrap" style={{ paddingTop: 12 }}>
<HStack gap={0.5} wrap="wrap">
<TabButton
label="Online Orders"
icon={<ShoppingCart size={14} />}

View File

@@ -125,7 +125,7 @@ export function UsersPage() {
/>
}
tabs={
<HStack gap={0.5} wrap="wrap" style={{ paddingTop: 12 }}>
<HStack gap={0.5} wrap="wrap">
<TabButton
label="Store users"
icon={<UserCog size={14} />}
@@ -426,8 +426,16 @@ function Chip({ label, tone }: { label: string | undefined; tone: keyof typeof T
/** Active is quiet, anything else is loud — an inactive account is the exception. */
function StatusChip({ status }: { status: string | undefined }) {
const isActive = (status ?? 'active').toLowerCase().startsWith('active');
const colour = isActive ? 'var(--color-success, #10b981)' : 'var(--color-ink-4)';
// A missing status is UNKNOWN, not active. Defaulting it to active painted a
// green dot and the word "Active" beside an account whose state the API never
// reported — which is the one case where somebody most needs to look.
const isActive = (status ?? '').toLowerCase().startsWith('active');
const isUnknown = !status;
const colour = isUnknown
? 'var(--color-ink-4)'
: isActive
? 'var(--color-success, #10b981)'
: 'var(--color-ink-4)';
return (
<span
style={{
@@ -442,7 +450,7 @@ function StatusChip({ status }: { status: string | undefined }) {
<span
style={{ width: 6, height: 6, borderRadius: 999, background: colour, flex: 'none' }}
/>
{isActive ? 'Active' : (status ?? 'Inactive')}
{isUnknown ? 'Unknown' : isActive ? 'Active' : status}
</span>
);
}

View File

@@ -292,3 +292,19 @@ test('problems come back worst bucket first', () => {
assert.equal(list[0]?.bucket, 'now');
assert.equal(list.at(-1)?.bucket, 'look');
});
/*
* The regression this file exists to prevent repeating.
*
* `GET /pos/health/location` answers `{location_id, total, online, terminals}`.
* It was typed as a bare array, so the first real sign-in hit `raw.map is not a
* function` — with no error boundary in the app at the time, that rendered as a
* blank page on the Store Admin console.
*/
test('a non-array from the health endpoint reads as no heartbeat, not a crash', () => {
const wrapper = { location_id: '1', total: 2, online: 1, terminals: [till({}), till({})] };
const summary = summariseBranch(wrapper as unknown as PosTerminalHealth[], NOW);
assert.equal(summary.state, 'no-heartbeat');
assert.equal(summary.total, 0);
assert.equal(summary.terminals.length, 0);
});

View File

@@ -251,7 +251,10 @@ export function rankOf(state: TerminalState): number {
* averaging is exactly how that hour goes unnoticed.
*/
export function summariseBranch(raw: PosTerminalHealth[], now: number): BranchSyncSummary {
const terminals = raw.map((entry) => readTerminal(entry, now));
// Defensive because this is a board on the landing page: an unexpected shape
// from the health endpoint should read as "no heartbeat", not take the whole
// console down with it. It already did once — see `PosLocationHealth`.
const terminals = (Array.isArray(raw) ? raw : []).map((entry) => readTerminal(entry, now));
const worst = terminals.reduce<TerminalState>(
(acc, terminal) => (RANK[terminal.state] > RANK[acc] ? terminal.state : acc),